From 0975218bbf827d894a53d23eadb2f42bb14c855e Mon Sep 17 00:00:00 2001
From: Randall Naar
Date: Wed, 1 Jul 2026 10:32:42 -0400
Subject: [PATCH] Updated transaction details to latest design.
---
client/src/app.js | 9 +-
client/src/components/block-grid.js | 88 ++++
client/src/components/elapsed-time.js | 17 +-
client/src/components/icons.js | 16 +
client/src/components/info-stat.js | 6 +
client/src/const.js | 1 +
client/src/views/block-details-card.js | 92 ++++
client/src/views/block.js | 24 +-
client/src/views/blocks.js | 18 +-
client/src/views/transactions.js | 4 +-
client/src/views/tx-privacy-analysis.js | 18 +-
client/src/views/tx.js | 533 +++++++++++++++++-------
client/src/views/util.js | 4 +-
flavors/bitcoin-testnet/extras.css | 9 -
flavors/bitcoin-testnet4/extras.css | 9 -
flavors/liquid-mainnet/extras.css | 10 -
flavors/liquid-testnet/extras.css | 9 -
www/style.css | 460 ++++++++++++++++----
18 files changed, 1010 insertions(+), 317 deletions(-)
create mode 100644 client/src/components/block-grid.js
create mode 100644 client/src/components/info-stat.js
create mode 100644 client/src/views/block-details-card.js
diff --git a/client/src/app.js b/client/src/app.js
index b27463aa..66ea08e7 100644
--- a/client/src/app.js
+++ b/client/src/app.js
@@ -224,6 +224,7 @@ export default function main({ DOM, HTTP, route, storage, scanner: scan$, search
// Single TX
, tx$ = reply('tx').merge(goTx$.mapTo(null)).startWith(null)
+ , txBlock$ = reply('tx-block').merge(goTx$.mapTo(null)).startWith(null)
// Currently collapsed tx/block ("details")
, openTx$ = togTx$.startWith(null).scan((prev, txid) => prev == txid ? null : txid)
@@ -348,7 +349,7 @@ export default function main({ DOM, HTTP, route, storage, scanner: scan$, search
, newBlockEntries$, newTxEntries$
, goBlock$, block$, blockStatus$, blockTxs$, nextBlockTxs$, prevBlockTxs$, openBlock$
, mempool$, mempoolRecent$, feeEst$, bitcoinMarketChart$
- , tx$, txAnalysis$, openTx$
+ , tx$, txBlock$, txAnalysis$, openTx$
, goAddr$, addr$, addrTxs$, addrQR$
, assetMap$, assetList$, goAssetList$, goAsset$, asset$, assetTxs$, unblinded$
, isReady$, loading$, page$, view$, title$
@@ -376,6 +377,10 @@ export default function main({ DOM, HTTP, route, storage, scanner: scan$, search
// fetch single tx (including confirmation status)
, goTx$.map(txid => ({ category: 'tx', method: 'GET', path: `/tx/${txid}` }))
+ // fetch the block containing a confirmed tx
+ , tx$.filter(tx => tx && tx.status && tx.status.confirmed && tx.status.block_hash)
+ .map(tx => ({ category: 'tx-block', method: 'GET', path: `/block/${tx.status.block_hash}` }))
+
// fetch address and its txs
, goAddr$.flatMap(d => [{ category: 'address', method: 'GET', path: `/address/${d.addr}` }
, d.last_txids.length
@@ -499,7 +504,7 @@ export default function main({ DOM, HTTP, route, storage, scanner: scan$, search
dbg({ goBlocks$, goBlock$, goTx$, goAddr$, togTx$, page$, lang$, vdom$, moreBlocks$
, openTx$, openBlock$, updateQuery$
- , state$, view$, block$, blockTxs$, blocks$, tx$, txAnalysis$, spends$, addr$
+ , state$, view$, block$, blockTxs$, blocks$, tx$, txBlock$, txAnalysis$, spends$, addr$
, tipHeight$, error$, loading$
, goSearch$, searchResult$, copy$, store$, navto$, scanning$, scan$
, assetMap$, goAssetList$, assetList$
diff --git a/client/src/components/block-grid.js b/client/src/components/block-grid.js
new file mode 100644
index 00000000..dc6ee8b9
--- /dev/null
+++ b/client/src/components/block-grid.js
@@ -0,0 +1,88 @@
+import { maxBlockWeight } from "../const";
+
+const GRID_LENGTH = 15;
+
+const drawBlockGrid = (canvas, blockWeight) => {
+ if (
+ typeof HTMLCanvasElement === "undefined" ||
+ !(canvas instanceof HTMLCanvasElement)
+ ) {
+ return;
+ }
+
+ const ctx = canvas.getContext("2d");
+ const rect = canvas.getBoundingClientRect();
+ const dpr = window.devicePixelRatio || 1;
+ const width = rect.width;
+ const height = rect.height;
+ 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)
+ : 0;
+ const filledCells = Math.round(fillRatio * GRID_LENGTH * GRID_LENGTH);
+ const styles = window.getComputedStyle(canvas);
+ const emptyColor =
+ styles.getPropertyValue("--block-grid-empty-color").trim() || "#141414";
+ const filledColor = styles
+ .getPropertyValue("--block-grid-filled-color")
+ .trim() || "#FA8A00";
+
+ canvas.width = Math.max(1, Math.round(width * dpr));
+ canvas.height = Math.max(1, Math.round(height * dpr));
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ ctx.clearRect(0, 0, width, height);
+
+ for (let index = 0; index < GRID_LENGTH * GRID_LENGTH; index += 1) {
+ const row = Math.floor(index / GRID_LENGTH);
+ const column = index % GRID_LENGTH;
+
+ ctx.fillStyle = index < filledCells ? filledColor : emptyColor;
+ ctx.fillRect(
+ column * (cellWidth + gap),
+ row * (cellHeight + gap),
+ cellWidth,
+ cellHeight,
+ );
+ }
+};
+
+const drawGrid = (vnode, blockWeight) => {
+ const draw = () => drawBlockGrid(vnode.elm, blockWeight);
+
+ if (typeof window !== "undefined" && window.requestAnimationFrame) {
+ window.requestAnimationFrame(draw);
+ return;
+ }
+
+ draw();
+};
+
+export const BlockGrid = ({ blockWeight } = {}) => {
+ const hasBlockWeight = Number.isFinite(blockWeight);
+ const percentage = hasBlockWeight
+ ? Math.min(
+ Math.max(
+ Math.round((blockWeight / maxBlockWeight) * 10_000) / 100,
+ 0,
+ ),
+ 100,
+ )
+ : 0;
+
+ return (
+
+ drawGrid(vnode, blockWeight)}
+ hook-postpatch={(_, vnode) => drawGrid(vnode, blockWeight)}
+ >
+
+ );
+};
diff --git a/client/src/components/elapsed-time.js b/client/src/components/elapsed-time.js
index 7b16df5c..407fca9e 100644
--- a/client/src/components/elapsed-time.js
+++ b/client/src/components/elapsed-time.js
@@ -1,4 +1,7 @@
const UPDATE_INTERVAL_MS = 60 * 1000;
+const MINUTES_PER_DAY = 24 * 60;
+const MINUTES_PER_YEAR = 365 * MINUTES_PER_DAY;
+const MINUTES_PER_MONTH = MINUTES_PER_YEAR / 12;
const formatElapsedTime = (timestamp) => {
const fromDate =
@@ -7,10 +10,18 @@ const formatElapsedTime = (timestamp) => {
0,
Math.floor((new Date() - fromDate) / UPDATE_INTERVAL_MS),
);
+ const years = Math.floor(diffMinutes / MINUTES_PER_YEAR);
+ const minutesAfterYears = diffMinutes % MINUTES_PER_YEAR;
+ const months = Math.floor(minutesAfterYears / MINUTES_PER_MONTH);
+ const minutesAfterMonths = Math.floor(
+ minutesAfterYears - months * MINUTES_PER_MONTH,
+ );
const units = [
- ["d", Math.floor(diffMinutes / (24 * 60))],
- ["h", Math.floor((diffMinutes % (24 * 60)) / 60)],
- ["m", diffMinutes % 60],
+ ["y", years],
+ ["mo", months],
+ ["d", Math.floor(minutesAfterMonths / MINUTES_PER_DAY)],
+ ["h", Math.floor((minutesAfterMonths % MINUTES_PER_DAY) / 60)],
+ ["m", minutesAfterMonths % 60],
];
const parts = units
.filter(([_, value]) => value > 0)
diff --git a/client/src/components/icons.js b/client/src/components/icons.js
index e57a616a..d5486491 100644
--- a/client/src/components/icons.js
+++ b/client/src/components/icons.js
@@ -16,12 +16,28 @@ export const ArrowsInSimpleIcon = ({ className } = {}) =>
+export const PrivacyAnalysisArrowIcon = ({ className } = {}) =>
+
+
+
+
export const CopyIcon = ({ className } = {}) =>
+export const PlusIcon = ({ className } = {}) =>
+
+
+
+
+
+export const MinusIcon = ({ className } = {}) =>
+
+
+
+
export const ClockIcon = ({ className } = {}) =>
diff --git a/client/src/components/info-stat.js b/client/src/components/info-stat.js
new file mode 100644
index 00000000..cdc5b7a2
--- /dev/null
+++ b/client/src/components/info-stat.js
@@ -0,0 +1,6 @@
+export const InfoStat = ({ title, value } = {}) => (
+
+);
diff --git a/client/src/const.js b/client/src/const.js
index 8f7141e5..c4dfb0e4 100644
--- a/client/src/const.js
+++ b/client/src/const.js
@@ -5,6 +5,7 @@ export const difficultyPeriod = 2016
export const maxMempoolTxs = 50
export const satoshisPerBitcoin = 100000000
export const averageNativeSegwitTransactionSize = 140
+export const maxBlockWeight = 4000000
export const nativeAssetId = process.env.NATIVE_ASSET_ID || '6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d'
export const nativeAssetLabel = process.env.NATIVE_ASSET_LABEL || 'BTC'
diff --git a/client/src/views/block-details-card.js b/client/src/views/block-details-card.js
new file mode 100644
index 00000000..ca2d87ca
--- /dev/null
+++ b/client/src/views/block-details-card.js
@@ -0,0 +1,92 @@
+import { BlockGrid } from "../components/block-grid";
+import { InfoStat } from "../components/info-stat";
+import { ElapsedTime } from "../components/elapsed-time";
+import {
+ formatTime,
+ formatVMB,
+ getBlockPercentageUsed,
+} from "./util";
+
+const formatInteger = (value) =>
+ Number.isFinite(value) ? value.toLocaleString() : "N/A";
+
+const BlockDetailsCard = ({ className, block, confirmed }) => {
+ const percentage = block
+ ? Math.min(Math.max(getBlockPercentageUsed(block.weight), 0), 100)
+ : 0;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
BLOCK FILLING
+
+ {block ? `${percentage}%` : "N/A"}
+
+
+
+
+
+
+
+
+ );
+};
+
+export default BlockDetailsCard;
diff --git a/client/src/views/block.js b/client/src/views/block.js
index 6423106b..9533ecd4 100644
--- a/client/src/views/block.js
+++ b/client/src/views/block.js
@@ -2,8 +2,8 @@ import layout from './layout'
import { txBox } from './tx'
import { updateQuery } from '../util'
import { formatTime, formatHex, formatNumber } from './util'
-import { blockTxsPerPage as perPage } from '../const'
import loader from '../components/loading'
+import { MinusIcon, PlusIcon } from '../components/icons'
const staticRoot = process.env.STATIC_ROOT || ''
@@ -122,8 +122,17 @@ export default ({ t, block: b, blockStatus: status, blockTxs, openTx, spends, op
-
{txsShownText(b.tx_count, goBlock.start_index, blockTxs && blockTxs.length, t)}
- { blockTxs ? blockTxs.map(tx => txBox( { ...tx, status: txsStatus }, { openTx, tipHeight, t, spends }))
+ { blockTxs ? blockTxs.map((tx, index) => txBox(
+ { ...tx, status: txsStatus },
+ {
+ openTx,
+ tipHeight,
+ t,
+ spends,
+ listingIndex: +goBlock.start_index + index + 1,
+ listingTotal: b.tx_count
+ }
+ ))
: loader() }
@@ -137,11 +146,6 @@ export default ({ t, block: b, blockStatus: status, blockTxs, openTx, spends, op
]
, { t, page, activeTab: 'recentBlocks', ...S })
-const txsShownText = (total, start, shown, t) =>
- (total > perPage && shown > 0)
- ? t`${ start > 0 ? `${start}-${+start+shown}` : shown} of ${total} Transactions`
- : t`${total} Transactions`
-
const pagingNav = (block, { nextBlockTxs, prevBlockTxs, t }) =>
process.browser
@@ -171,6 +175,6 @@ const btnDetails = (blockhash, isOpen, query, t) => process.browser
const btnDetailsContent = (isOpen, t) =>
-
{t`Details`}
-
+
{t`Details`}
+
diff --git a/client/src/views/blocks.js b/client/src/views/blocks.js
index f74e6c55..51e2b233 100644
--- a/client/src/views/blocks.js
+++ b/client/src/views/blocks.js
@@ -6,6 +6,7 @@ import {
} from "./util";
import loader from "../components/loading";
import { BlockIcon, ClockIcon, CopyIcon } from "../components/icons";
+import { InfoStat } from "../components/info-stat";
const staticRoot = process.env.STATIC_ROOT || "";
@@ -67,18 +68,11 @@ export const blks = (blocks, viewMore, { t, ...S }) => (
-
-
TRANSACTIONS
-
- {formatNumber(b.tx_count).toLocaleString()}
-
-
-
-
SIZE
-
- {formatVMB(b.size, "MB")}
-
-
+
+
diff --git a/client/src/views/transactions.js b/client/src/views/transactions.js
index d9876c82..dfba6d5a 100644
--- a/client/src/views/transactions.js
+++ b/client/src/views/transactions.js
@@ -21,7 +21,7 @@ export const transactions = (txs, viewMore, { t, ...S }) => (
) : !txs.length ? (
{t`No recent transactions`}
) : (
-
+
@@ -38,7 +38,7 @@ export const transactions = (txs, viewMore, { t, ...S }) => (
-
+
{txs.map((txOverview) => {
const feerate = txOverview.fee / txOverview.vsize;
const feeClass = feeRateClass(feerate, S.feeEst);
diff --git a/client/src/views/tx-privacy-analysis.js b/client/src/views/tx-privacy-analysis.js
index bbfdd805..dbcd8930 100644
--- a/client/src/views/tx-privacy-analysis.js
+++ b/client/src/views/tx-privacy-analysis.js
@@ -3,6 +3,8 @@
// the issues marked as "danger" (displayed in red) are the ones where it is relatively trivial
// to avoid the issue by changing habits and/or changing wallet software. the ones marked as "warning"
// (displayed in orange) are more difficult to do something about.
+import { PrivacyAnalysisArrowIcon } from '../components/icons'
+
const messages = {
'internal-address-reuse': [
'danger'
@@ -56,15 +58,23 @@ const messages = {
const msgOrdering = Object.values(messages)
+const privacyAnalysisLink = (className, label, url) =>
+
+ {label}
+
+
+
export default (analysis, t) =>
analysis.length ?
{analysis.map(msg => messages[msg])
.sort((a, b) => msgOrdering.indexOf(a) - msgOrdering.indexOf(b))
.map(([ type, title, desc, url ]) =>
- {t(title) + '➚'} )
+ {privacyAnalysisLink(`text-${type}`, t(title), url)} )
}
- :
- {t`This transaction doesn't violate any of the privacy gotchas we cover. Read on other potential ways it might leak privacy.`}➚
-
+ : privacyAnalysisLink(
+ 'text-success privacy-analysis-no-issues',
+ t`This transaction doesn't violate any of the privacy gotchas we cover. Read on other potential ways it might leak privacy.`,
+ 'https://en.bitcoin.it/wiki/Privacy#Blockchain_attacks_on_privacy'
+ )
diff --git a/client/src/views/tx.js b/client/src/views/tx.js
index b706aca3..403deca7 100644
--- a/client/src/views/tx.js
+++ b/client/src/views/tx.js
@@ -1,195 +1,414 @@
-import layout from './layout'
-import search from './search'
-import vinView from './tx-vin'
-import voutView from './tx-vout'
-import privacyAnalysisView from './tx-privacy-analysis'
-import segwitGainsView from './tx-segwit-gains'
-import { formatSat, formatTime, formatVMB, formatNumber } from './util'
-import { isAllUnconfidential, isAllNative, isRbf, outTotal, updateQuery } from '../util'
+import layout from "./layout";
+import search from "./search";
+import vinView from "./tx-vin";
+import voutView from "./tx-vout";
+import privacyAnalysisView from "./tx-privacy-analysis";
+import segwitGainsView from "./tx-segwit-gains";
+import { formatSat, formatTime, formatVMB, formatNumber } from "./util";
+import {
+ isAllUnconfidential,
+ isAllNative,
+ isRbf,
+ outTotal,
+ updateQuery,
+} from "../util";
+import {
+ BlockIcon,
+ CopyIcon,
+ MinusIcon,
+ PlusIcon,
+ TxArrowsIcon,
+} from "../components/icons";
+import { InfoStat } from "../components/info-stat";
+import BlockDetailsCard from "./block-details-card";
// Require behind env conditional so it gets removed by `envify` on non-elements builds
-const deduceBlinded = process.env.IS_ELEMENTS && require('../lib/deduce-blinded').deduceBlinded
+const deduceBlinded =
+ process.env.IS_ELEMENTS && require("../lib/deduce-blinded").deduceBlinded;
// show a warning for payments paying more than 1.2x the recommended amount for 2 blocks confirmation
-const OVERPAYMENT_WARN = 1.2
+const OVERPAYMENT_WARN = 1.2;
-const findSpend = (spends, txid, vout) => spends[txid] && spends[txid][vout]
+const findSpend = (spends, txid, vout) => spends[txid] && spends[txid][vout];
-export default ({ t, tx, tipHeight, spends, openTx, page, unblinded, ...S }) => {
+export default ({
+ t,
+ tx,
+ tipHeight,
+ spends,
+ openTx,
+ page,
+ unblinded,
+ txBlock,
+ ...S
+}) => {
if (!tx || !S.txAnalysis) return;
+ const block =
+ tx.status && txBlock && txBlock.id === tx.status.block_hash
+ ? txBlock
+ : null;
+
// Amount/asset unblinding (Elements only)
if (unblinded && !unblinded.error) {
- const { matched, total } = unblinded.tryUnblindTx(tx)
- if (matched == 0) unblinded.error = t`None of the given blinders matches this transaction.`
- else if (matched < total) unblinded.error = t`${total-matched} of the given blinders did not match this transaction.`
+ const { matched, total } = unblinded.tryUnblindTx(tx);
+ if (matched == 0)
+ unblinded.error = t`None of the given blinders matches this transaction.`;
+ else if (matched < total)
+ unblinded.error = t`${total - matched} of the given blinders did not match this transaction.`;
}
return layout(
- [
-
+ [
-
-
{t`Transaction`}
-
-
{tx.txid}
- { process.browser &&
}
+ {txHeader(tx, { t, tipHeight, ...S })}
+ {unblinded && unblinded.error ? (
+
+ {t`Warning:`} {unblinded.error.toString()}
-
-
-
,
-
- {txHeader(tx, { t, tipHeight, ...S })}
- {(unblinded && unblinded.error) ?
{t`Warning:`} {unblinded.error.toString()}
:
}
- {txBox(tx, { openTx, tipHeight, t, spends, query: page.query, ...S })}
-
- ]
- , { t, page, activeTab: 'recentTxs', ...S })
-}
+ ) : (
+
+ )}
+ {txBox(tx, {
+ openTx,
+ tipHeight,
+ t,
+ spends,
+ query: page.query,
+ ...S,
+ showTxId: false,
+ showFooter: false,
+ })}
+ {tx.status && tx.status.confirmed ? (
+
+
+
+
+
+
Transaction Block
+
+
+
+ ) : null}
+
,
+ ],
+ { t, page, activeTab: "recentTxs", ...S },
+ );
+};
const confirmationText = (status, tipHeight, t) =>
- !status.confirmed ? t`Unconfirmed` : tipHeight ? t`${tipHeight - status.block_height + 1} Confirmations` : t`Confirmed`
+ !status.confirmed
+ ? t`Unconfirmed`
+ : tipHeight
+ ? t`${tipHeight - status.block_height + 1} Confirmations`
+ : t`Confirmed`;
-export const txBox = (tx, { t, openTx, tipHeight, spends, query, unblinded, ...S }) => {
- const vopt = { isOpen: (openTx == tx.txid), query, t, ...S }
+export const txBox = (
+ tx,
+ {
+ t,
+ openTx,
+ tipHeight,
+ spends,
+ query,
+ unblinded,
+ listingIndex,
+ listingTotal,
+ showTxId = true,
+ showFooter = true,
+ ...S
+ },
+) => {
+ const vopt = { isOpen: openTx == tx.txid, query, t, ...S };
+ const hasListingHeader =
+ Number.isFinite(listingIndex) && Number.isFinite(listingTotal);
// Try deducing unknown blinded ins/outs (elements only)
- if (process.env.IS_ELEMENTS) deduceBlinded(tx)
+ if (process.env.IS_ELEMENTS) deduceBlinded(tx);
- return
-
-
- {btnDetails(tx.txid, vopt.isOpen, query, t)}
-
-
-
{tx.vin.map((vin, index) => vinView(vin, { ...vopt, index }))}
-
-
-
-
-
+ return (
+
+ {hasListingHeader ? (
+
+
+
+
+
+ {t`${listingIndex} of ${listingTotal} Transactions`}
+
+ ) : null}
+
+ {showTxId ? (
+
+ ) : (
+ ""
+ )}
+ {btnDetails(tx.txid, vopt.isOpen, query, t)}
+
+
+ {tx.vin.map((vin, index) => vinView(vin, { ...vopt, index }))}
+
+
-
{tx.vout.map((out, index) =>
- voutView(out, { ...vopt, index, spend: findSpend(spends, tx.txid, index) }))}
-
-
-
-
-
-
- {tx.status &&
{confirmationText(tx.status, tipHeight, t)} {!tx.status.confirmed && isRbf(tx) ? t`(RBF)` : ''} }
-
{
- !isAllUnconfidential(tx) ? t`Confidential`
- : isAllNative(tx) ? formatSat(outTotal(tx))
- : ''}
+
+ {tx.vout.map((out, index) =>
+ voutView(out, {
+ ...vopt,
+ index,
+ spend: findSpend(spends, tx.txid, index),
+ }),
+ )}
+
+ {showFooter ? transactionFooter(tx, tipHeight, t) : null}
-
-}
+ );
+};
-const btnDetails = (txid, isOpen, query, t) => process.browser
- // dynamic button in browser env
- ?
{btnDetailsContent(isOpen, t)}
- // or a plain link in server-side rendered env
- :
{btnDetailsContent(isOpen, t)}
+const btnDetails = (txid, isOpen, query, t) =>
+ process.browser ? (
+ // dynamic button in browser env
+
+ {btnDetailsContent(isOpen, t)}
+
+ ) : (
+ // or a plain link in server-side rendered env
+
+ {btnDetailsContent(isOpen, t)}
+
+ );
-const btnDetailsContent = (isOpen, t) =>
+const btnDetailsContent = (isOpen, t) => (
-
{t`Details`}
-
+
{t`Details`}
+
+);
-const txHeader = (tx, { tipHeight, mempool, feeEst, t
- , txAnalysis: { feerate, mempoolDepth, confEstimate, overpaying, privacyAnalysis, segwitGains } }) => {
+const txHeader = (
+ tx,
+ {
+ tipHeight,
+ feeEst,
+ t,
+ txAnalysis: {
+ feerate,
+ mempoolDepth,
+ confEstimate,
+ overpaying,
+ privacyAnalysis,
+ segwitGains,
+ },
+ },
+) => {
+ const isConfirmed = tx.status && tx.status.confirmed;
+ const isLoadingEta = confEstimate == null || mempoolDepth == null;
+ const etaMultiplier = process.env.IS_ELEMENTS ? 1 : 10;
+ const etaLabel = isLoadingEta
+ ? t`Loading...`
+ : confEstimate == -1
+ ? t`Unknown`
+ : `~${confEstimate * etaMultiplier} min`;
+ const confirmationTime =
+ isConfirmed && Number.isFinite(tx.status.block_time)
+ ? formatTime(tx.status.block_time)
+ : "N/A";
+ const segwitSavings = segwitGainsView(segwitGains, t);
return (
-
-
-
{t`Status`}
-
{confirmationText(tx.status, tipHeight, t)}
-
- {tx.status.confirmed && [
-
-
{t`Included in Block`}
-
-
- ,
-
{t`Block height`}
-
{tx.status.block_height}
-
- ,
-
{t`Block timestamp`}
-
{formatTime(tx.status.block_time)}
-
- ]}
+
+
+
+
+
+
+
Transaction Details
+
+
+
+
{tx.txid}
+
+
+
+
+ {!isConfirmed ? (
+
+ ) : null}
+
{confirmationText(tx.status, tipHeight, t)}
+
+
+
+
+
+
+
+
- { !tx.status.confirmed && feerate != null &&
-
{t`ETA`}
-
{confEstimate == null || mempoolDepth == null ? {t`Loading...`}
- : confEstimate == -1 ? t`unknown (${formatVMB(mempoolDepth)} from tip)`
- : t`in ${confEstimate} blocks (${formatVMB(mempoolDepth)} from tip)` }
-
-
}
-
- { feerate != null &&
-
{t`Transaction fees`}
-
-
{t`${formatSat(tx.fee)} (${feerate.toFixed(2)} sat/vB)`}
- { overpaying > OVERPAYMENT_WARN &&
-
OVERPAYMENT_WARN*1.5 ? 'danger' : 'warning' } mb-0`} title={t`compared to the suggested fee of ${feeEst[2].toFixed(1)} sat/vB for confirmation within 2 blocks`}>
- ⓘ {t`overpaying by ${Math.round((overpaying-1)*100)}%`}
-
- }
+ {isRbf(tx) ? (
+
+ ) : null}
+
+
+
+ {isConfirmed ? (
+
+ ) : (
+
+
ETA
+
+
+ {!isLoadingEta ? (
+
+ {confEstimate != -1 ? (
+
+ {confEstimate == 1
+ ? t`1 Block`
+ : t`${confEstimate} Blocks`}
+
+ ) : null}
+
+ {t`${formatVMB(mempoolDepth)} from tip`}
+
+
+ ) : null}
+
+
+ )}
+
+
+
TRANSACTION FEES
+
+ {feerate != null ? (
+
+
+
+ {formatSat(tx.fee)}
+
+
+ {`(${feerate.toFixed(2)} sat/vB)`}
+
+
+ {overpaying > OVERPAYMENT_WARN ? (
+
OVERPAYMENT_WARN * 1.5
+ ? "danger"
+ : "warning"
+ }`}
+ title={t`compared to the suggested fee of ${feeEst[2].toFixed(1)} sat/vB for confirmation within 2 blocks`}
+ >
+ {t`Overpaying by ${Math.round((overpaying - 1) * 100)}%`}
+
+ ) : null}
+
+ ) : (
+
N/A
+ )}
+
+
+
+
+
+
+
PRIVACY ANALYSIS
+
+
{privacyAnalysisView(privacyAnalysis, t)}
+
+
+
+
+
SEGWIT FEE SAVINGS
+
+
{segwitSavings || "N/A"}
+
+
+
+
+
+
+ {transactionFooter(tx, tipHeight, t)}
-
}
+
+ );
+};
-
-
{t`Size`}
-
{`${formatNumber(tx.size)} B`}
-
-
-
{t`Virtual size`}
-
{`${formatNumber(Math.ceil(tx.weight/4))} vB`}
-
-
-
{t`Weight units`}
-
{`${formatNumber(tx.weight)} WU`}
-
- { tx.discount_vsize != null &&
-
{t`Discount virtual size`}
-
{`${formatNumber(tx.discount_vsize)} vB`}
-
}
- { tx.discount_weight != null &&
-
{t`Discount weight units`}
-
{`${formatNumber(tx.discount_weight)} WU`}
-
}
-
-
{t`Version`}
-
{tx.version}
-
-
-
{t`Lock time`}
-
{tx.locktime}
-
- { isRbf(tx) &&
-
{t`Replace by fee`}
-
{t`Opted in`}
-
}
-
- { !!(segwitGains.realizedGains || segwitGains.potentialBech32Gains) &&
-
{t`SegWit fee savings`}
-
{segwitGainsView(segwitGains, t)}
-
}
-
-
-
{t`Privacy analysis`}
-
{privacyAnalysisView(privacyAnalysis, t)}
-
+const transactionFooter = (tx, tipHeight, t) => {
+ const isConfirmed = tx.status && tx.status.confirmed;
-
)
-}
+ return (
+
+
+ {tx.status && (
+
+ {confirmationText(tx.status, tipHeight, t)}{" "}
+ {!tx.status.confirmed && isRbf(tx) ? t`(RBF)` : ""}
+
+ )}
+
+ {!isAllUnconfidential(tx)
+ ? t`Confidential`
+ : isAllNative(tx)
+ ? formatSat(outTotal(tx))
+ : ""}
+
+
+
+ );
+};
diff --git a/client/src/views/util.js b/client/src/views/util.js
index 6c031bd7..de80c704 100644
--- a/client/src/views/util.js
+++ b/client/src/views/util.js
@@ -1,6 +1,6 @@
import moveDec from 'move-decimal-point'
import { sat2btc } from 'fmtbtc'
-import { nativeAssetLabel } from '../const'
+import { maxBlockWeight, nativeAssetLabel } from '../const'
import { isNativeOut } from '../util'
const DEFAULT_ISSUED_PRECISION = 0
@@ -102,7 +102,7 @@ export const formatRelativeTime = (fromDate, toDate = new Date()) => {
}
export const getBlockPercentageUsed = blockWeight =>
- Math.round((blockWeight / 4000000) * 10000) / 100
+ Math.round((blockWeight / maxBlockWeight) * 10000) / 100
export const formatJson = obj =>
JSON.stringify(obj, null, 1)
diff --git a/flavors/bitcoin-testnet/extras.css b/flavors/bitcoin-testnet/extras.css
index ac8bf6b1..8b1ff4c7 100644
--- a/flavors/bitcoin-testnet/extras.css
+++ b/flavors/bitcoin-testnet/extras.css
@@ -2,15 +2,6 @@
background: #8C8C8C;
}
-.details-btn > div {
- color: rgba(168, 184, 201, 1);
- border: 1px solid rgba(168, 184, 201, 1);
-}
-
-.transaction-box > .footer > div:nth-child(3) {
- color: rgba(168, 184, 201, 1);
-}
-
.navbar {
background-image: linear-gradient(-90deg, rgba(84, 103, 124, 1) 0%, rgba(29, 72, 111, 1) 18%, rgba(24, 53, 80, 1) 36%, rgba(29, 37, 48, 1) 58%, rgba(14, 16, 17, 1) 100%);
}
diff --git a/flavors/bitcoin-testnet4/extras.css b/flavors/bitcoin-testnet4/extras.css
index a7bd4b2d..b5d737b9 100644
--- a/flavors/bitcoin-testnet4/extras.css
+++ b/flavors/bitcoin-testnet4/extras.css
@@ -2,15 +2,6 @@
background: #8C8C8C;
}
-.details-btn > div {
- color: rgba(168, 184, 201, 1);
- border: 1px solid rgba(168, 184, 201, 1);
-}
-
-.transaction-box > .footer > div:nth-child(3) {
- color: rgba(168, 184, 201, 1);
-}
-
.navbar {
background-image: linear-gradient(-90deg, rgba(84, 103, 124, 1) 0%, rgba(29, 72, 111, 1) 18%, rgba(24, 53, 80, 1) 36%, rgba(29, 37, 48, 1) 58%, rgba(14, 16, 17, 1) 100%);
}
diff --git a/flavors/liquid-mainnet/extras.css b/flavors/liquid-mainnet/extras.css
index f1ba7c97..2851dec5 100644
--- a/flavors/liquid-mainnet/extras.css
+++ b/flavors/liquid-mainnet/extras.css
@@ -10,16 +10,6 @@
border-radius: 10px;
}
-.details-btn > div {
- color: var(--accent-color);
- border: 1px solid var(--accent-color);
- background: none;
-}
-
-.transaction-box > .footer > div:nth-child(3) {
- color: var(--accent-color);
-}
-
.navbar {
background-image: linear-gradient(-90deg, rgba(13, 141, 119, 1) 0%, rgba(17, 103, 97, 1) 16%, rgba(25, 68, 74, 1) 35%, rgba(29, 42, 48, 1) 57%, rgba(14, 16, 17, 1) 100%);
}
\ No newline at end of file
diff --git a/flavors/liquid-testnet/extras.css b/flavors/liquid-testnet/extras.css
index 6966607c..6b1c0deb 100644
--- a/flavors/liquid-testnet/extras.css
+++ b/flavors/liquid-testnet/extras.css
@@ -2,15 +2,6 @@
background: #8C8C8C;
}
-.details-btn > div {
- color: rgba(168, 184, 201, 1);
- border: 1px solid rgba(168, 184, 201, 1);
-}
-
-.transaction-box > .footer > div:nth-child(3) {
- color: rgba(168, 184, 201, 1);
-}
-
.navbar {
background-image: linear-gradient(-90deg, rgba(84, 103, 124, 1) 0%, rgba(29, 72, 111, 1) 18%, rgba(24, 53, 80, 1) 36%, rgba(29, 37, 48, 1) 58%, rgba(14, 16, 17, 1) 100%);
}
diff --git a/www/style.css b/www/style.css
index dde0ce11..ffb7d685 100644
--- a/www/style.css
+++ b/www/style.css
@@ -122,8 +122,13 @@ html {
--surface-primary-color: #1C1C1C;
--surface-primary-alt-color: #26221D;
--surface-secondary-color: #262626;
- --foreground-muted: #B5BDC2;
--page-gap: 24px;
+ --success-color: #17C964;
+ --warning-color: #E2BA1B;
+ --danger-color: #DB3B3E;
+ --foreground-link-color: #00C3FF;
+ --foreground-muted: #B5BDC2;
+ --radius-rounded-xl: 12px;
}
body {
@@ -201,7 +206,7 @@ ul {
}
}
-.font-h5 { font-size: 16px; font-weight: 700; font-family: "Chakra Petch", sans-serif; line-height: normal; letter-spacing: normal; font-style: normal; }
+.font-h5 { font-size: 16px; font-weight: 700; font-family: "Rigid Square", sans-serif; line-height: normal; letter-spacing: normal; font-style: normal; }
@media only screen and (max-width: 475px) {
.font-h5 {
@@ -1021,6 +1026,7 @@ table th {
display: table;
table-layout: fixed;
width: 100%;
+ margin-top: 50px;
}
.prev-next-blocks-btns > * {
@@ -1105,6 +1111,8 @@ table th {
margin-top: 40px;
display: flex;
justify-content: center;
+ line-break: anywhere;
+ flex-wrap: wrap;
}
.error-text-center h1 {
@@ -1118,22 +1126,37 @@ table th {
}
.text-warning {
- color: #ffc107 !important;
+ color: var(--warning-color) !important;
text-decoration: none;
}
.text-danger {
- color: #DC3544 !important;
+ color: var(--danger-color) !important;
}
.text-success {
- color: #28a745 !important;
+ color: var(--success-color) !important;
}
.text-info {
color: #17a2b8 !important;
}
+.privacy-analysis-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 9px;
+ font-size: 16px;
+}
+
+.privacy-analysis-arrow {
+ flex: 0 0 auto;
+}
+
+.privacy-analysis-no-issues {
+ font-size: 14px;
+}
+
.stats-table > div {
height: 52px;
border-top: 1px solid var(--surface-secondary-color);
@@ -1192,14 +1215,11 @@ table th {
vertical-align: top;
}
-.transaction-box {
- margin-top: 24px;
-}
-
.transaction-box {
background-color: var(--surface-primary-color);
- padding: 0 24px;
- border-radius: 5px;
+ padding: 24px;
+ border-radius: var(--radius-rounded-xl);
+ margin-top: 24px;
}
.transaction-box.loading-transaction {
@@ -1213,24 +1233,13 @@ table th {
width: 75px;
}
-.transaction-box > .header {
- height: 88px;
-}
-
-.transaction-box > .header {
- display: table;
- table-layout: fixed;
- width: 100%;
-}
-
-.transaction-box > .header > * {
- display: table-cell;
- vertical-align: middle;
+.transaction-box-header {
+ display: flex;
+ align-items: center;
}
-.transaction-box > .header a {
- font-size: 16px;
- font-weight: 600;
+.transaction-box > .table-header + .transaction-box-header {
+ margin-top: 24px;
}
.float-right {
@@ -1238,7 +1247,15 @@ table th {
}
.details-btn {
- width: 140px;
+ width: fit-content;
+ display: flex;
+ align-items: center;
+ height: 44px;
+ margin-left: auto;
+}
+
+.details-btn p {
+ font-weight: 700;
}
.details-btn > div {
@@ -1250,14 +1267,13 @@ table th {
-webkit-touch-callout: none;
user-select: none;
cursor: pointer;
- max-width: 140px;
- width: 100%;
- height: 50px;
- background-color: var(--accent-color);
- color: #0C0C0F;
+ color: #00C3FF;
display: table;
table-layout: fixed;
float: right;
+ display: flex;
+ gap: 8px;
+ font-size: 16px;
}
.details-btn > div > * {
@@ -1277,22 +1293,6 @@ table th {
width: 30px;
}
-.details-btn .plus:before, .details-btn .minus:before {
- content: '';
- width: 11px;
- height: 11px;
- display: block;
- background-position: center;
- background-size: contain;
- background-repeat: no-repeat;
-}
-.details-btn .plus:before {
- background-image: url(img/icons/plus.png);
-}
-.details-btn .minus:before {
- background-image: url(img/icons/minus.png);
-}
-
.block-details-btn {
margin: 20px;
}
@@ -1301,6 +1301,7 @@ table th {
display: table;
table-layout: fixed;
width: 100%;
+ margin-top: 12px;
}
.transaction-box > .ins-and-outs > * {
@@ -1425,41 +1426,20 @@ table th {
.vin.active, .vout.active {
background: rgba(255, 255, 255, .1) !important;
}
-.transaction-box > .footer {
- height: 64px;
-}
-
-.transaction-box > .footer {
- display: table;
- table-layout: fixed;
- width: 100%;
-}
-
-.transaction-box > .footer > * {
- display: table-cell;
- vertical-align: middle;
-}
-
-.transaction-box > .footer > div:nth-child(1) > span:nth-child(1) {
- color: #78838e;
- text-transform: uppercase;
- margin-right: 36px;
-}
-.transaction-box > .footer > div:nth-child(2) {
- width: 74px;
-}
-
-.transaction-box > .footer > div:nth-child(3) {
- text-align: right;
- text-transform: uppercase;
- color: rgba(255, 187, 0, 1);
- font-size: 16px;
+.transaction-table-footer {
+ display: flex;
+ margin-top: 24px;
}
-.transaction-box > .footer > div:nth-child(3) > span:nth-child(2) {
- margin-left: 24px;
- text-transform: none;
+.transaction-table-footer-confirmation-text {
+ margin-left: auto;
+ display: flex;
+ width: fit-content;
+ font-family: 'Rigid Square';
+ font-size: 20px;
+ font-weight: 700;
+ gap: 24px;
}
.addr-header-layout {
@@ -3700,7 +3680,7 @@ a.back-link img{
margin: 0;
}
-.block-stat {
+.info-stat {
display: flex;
flex-direction: column;
gap: 6px;
@@ -3709,16 +3689,16 @@ a.back-link img{
justify-content: center;
}
-.block-stat:last-child {
+.info-stat:last-child {
border-right: none;
}
-.block-stat-title {
+.info-stat-title {
font-size: 10px;
color: #B5BDC2;
}
-.block-stat-value {
+.info-stat-value {
font-size: 14px;
font-weight: 600;
}
@@ -3770,25 +3750,40 @@ a.back-link img{
/******* TX TABLE *******/
-.transaction-table {
+.latest-transactions-table {
background-color: var(--surface-primary-color);
padding: 24px;
border-radius: 12px;
}
-.transaction-table-body {
+.latest-transactions-table-body {
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
+.transaction-table {
+ margin-top: var(--page-gap);
+ background-color: var(--surface-primary-color);
+ padding: 24px;
+ border-radius: var(--radius-rounded-xl);
+}
+
+.transaction-table-body {
+ margin-top: 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+}
+
.transaction-table-transaction-id {
flex: 1;
}
.transaction-table-transaction-id p {
font-size: 16px;
+ width: 110px;
}
.transaction-table-transaction-value {
@@ -3971,10 +3966,108 @@ a.back-link img{
.mobile-block-icon-container svg {
width: 14px;
}
+
+ .tx-details-txid {
+ flex-wrap: wrap;
+ line-break: anywhere;
+ }
+
+ .transaction-details-row {
+ flex-direction: column;
+ }
+
+ .info-stats-row {
+ flex-wrap: wrap;
+ }
}
.overview {
- margin-top: 24px;
+ margin-top: var(--page-gap);
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.info-stats-row {
+ display: flex;
+ gap: 12px;
+}
+
+.confirmation-status-badge {
+ display: inline-flex;
+ align-items: center;
+ height: fit-content;
+ border-radius: 4px;
+ box-sizing: initial;
+ padding: 2px 8px;
+ font-size: 11px;
+ background-color: rgb(from var(--warning-color) r g b / 20%);
+ color: var(--warning-color);
+ border-radius: 9999px;
+}
+
+.confirmation-status-dot {
+ position: relative;
+ width: 10px;
+ height: 10px;
+ margin-right: 4px;
+ flex: 0 0 auto;
+}
+
+.confirmation-status-dot-front,
+.confirmation-status-dot-middle,
+.confirmation-status-dot-back {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ border-radius: 50%;
+ background-color: #E2BA1B;
+ transform: translate(-50%, -50%);
+}
+
+.confirmation-status-dot-front {
+ width: 6px;
+ height: 6px;
+}
+
+.confirmation-status-dot-middle {
+ width: 8px;
+ height: 8px;
+ opacity: .4;
+}
+
+.confirmation-status-dot-back {
+ width: 10px;
+ height: 10px;
+ opacity: .2;
+}
+
+.confirmation-status-badge.success {
+ background-color: rgba(23, 201, 100, .2);
+ color: #17C964;
+}
+
+.eta-label {
+ height: fit-content;
+ border-radius: 4px;
+ box-sizing: initial;
+ padding: 2px 8px;
+ font-size: 11px;
+ background-color: var(--surface-primary-color);
+ border-radius: 9999px;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.eta-label .dot {
+ background-color: var(--warning-color);
+ width: 6px;
+ height: 6px;
+ border-radius: 9999px;
+}
+
+.transaction-details {
display: flex;
flex-direction: column;
gap: 12px;
@@ -4177,3 +4270,194 @@ a.back-link img{
.difficulty-adjustment-metric-card {
flex: 1;
}
+
+.tx-details-txid {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.tx-details-txid-text {
+ color: var(--foreground-link-color);
+ text-decoration: underline !important;
+ font-size: 16px;
+ font-family: Inter;
+ cursor: pointer;
+}
+
+.transaction-details-row {
+ display: flex;
+ gap: 12px;
+}
+
+.tx-overview-panel {
+ display: flex;
+ align-items: center;
+ padding: 12px;
+ background-color: var(--surface-secondary-color);
+ border-radius: 4px;
+ flex: 1;
+ gap: 24px;
+}
+
+.tx-overview-panel-label {
+ font-size: 10px;
+ color: #B5BDC2;
+ text-transform: capitalize;
+ white-space: nowrap;
+}
+
+.tx-overview-panel-details {
+ display: flex;
+ margin-left: auto;
+ text-align: right;
+ gap: 12px;
+ align-items: center;
+ font-size: 14px;
+}
+
+.eta-blocks {
+ color: #E2BA1B;
+ font-size: 14px;
+}
+
+.eta-blocks-from-tip {
+ font-size: 11px;
+ color: var(--foreground-muted);
+}
+
+.overview-fees {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+}
+
+.tx-overview-panel-fee {
+ font-size: 14px;
+ font-weight: 600;
+}
+
+.tx-overview-panel-fee-p-vb {
+ color: var(--foreground-muted);
+ font-size: 11px;
+ font-weight: 400;
+}
+
+
+.block-details-card {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ margin-top: 24px;
+ padding: 24px;
+ background-color: var(--surface-primary-color);
+ border-radius: 12px;
+}
+
+.block-details-card-header {
+ display: flex;
+ gap: 12px;
+ align-items: center;
+ min-height: 20px;
+ margin-top: 10px;
+}
+
+.block-details-card-grid {
+ /* These variables are pulled by the BlockGrid component */
+ --block-grid-empty-color: var(--surface-secondary-color);
+ --block-grid-filled-color: var(--accent-color);
+ flex: 0 0 auto;
+ padding: 10px;
+ border: 2px solid #FA8A0033;
+ border-radius: 4px;
+ background-color: var(--surface-primary-color);
+ width: fit-content;
+}
+
+.block-details-card-grid canvas {
+ display: block;
+ width: 148px;
+ height: 148px;
+}
+
+.block-details-card-content {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ min-width: 0;
+ margin-left: 16px;
+ padding: 10px 0px;
+}
+
+.block-details-card-stats {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 24px;
+ margin-top: 12px;
+}
+
+.block-details-card-timestamp {
+ margin: 0;
+ color: var(--foreground-muted);
+ font-size: 14px;
+ font-weight: 400;
+}
+
+.block-details-card-summary {
+ display: flex;
+}
+
+.block-details-card-progress {
+ margin-top: auto;
+}
+
+.block-details-card-progress-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ color: var(--foreground-muted);
+ font-size: 11px;
+}
+
+.block-details-card-progress-header .usage-number {
+ color: #fff;
+}
+
+.block-details-card-usage-bar {
+ position: relative;
+ height: 10px;
+ border-radius: 13px;
+ background-color: var(--surface-secondary-color);
+ overflow: hidden;
+ margin-top: 6px;
+}
+
+.block-details-card-usage-bar-fill {
+ height: 100%;
+ border-radius: 13px;
+ background-image: linear-gradient(
+ 90deg,
+ #22C55E 0%,
+ #FA3600 100%
+ );
+ background-repeat: no-repeat;
+}
+
+.transaction-block-details {
+ margin-top: 12px;
+}
+
+@media only screen and (max-width: 820px) {
+ .block-details-card-summary {
+ flex-direction: column;
+ }
+
+ .block-details-card-content {
+ margin-top: 16px;
+ margin-left: 0;
+ }
+
+ .block-details-card-progress {
+ margin-top: 16px;
+ }
+}