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 } = {}) =>
+
{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 ? ( + + #{block.height.toLocaleString()} + + ) : ( + "-" + )} +

+ +

+ {block ? ( + + ago + + ) : ( + "Loading block..." + )} +

+ + {confirmed ? ( +
+

Confirmed

+
+ ) : null} +
+ +
+ + +
+ +
+
+

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`}

+
{isOpen ? : }
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 ? ( +
+ + {tx.txid} + +
+ +
+
+ ) : ( + "" + )} + {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`}

+
{isOpen ? : }
+); -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 ? ( +
+

CONFIRMATION TIME

+
+

{confirmationTime}

+
+
+ ) : ( +
+

ETA

+
+
+
+

{etaLabel}

+
+ {!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; + } +}