diff --git a/README.md b/README.md index d0c68f8b1..ab387276d 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,7 @@ All options are optional. - `API_URL` - URL for HTTP REST API (defaults to `/api`, change if the API is available elsewhere) - `CANONICAL_URL` - absolute base url for user interface (optional, only required for opensearch and canonical link tags) - `NATIVE_ASSET_LABEL` - the name of the network native asset (defaults to `BTC`) +- `TARGET_BLOCK_INTERVAL_SECONDS` - expected time between blocks, used for confirmation ETAs (defaults to `60` for Elements chains and `600` otherwise) - `SITE_TITLE` - website title for `` (defaults to `Block Explorer`) - `SITE_DESC` - meta description (defaults to `Esplora Block Explorer`) - `HOME_TITLE` - text for homepage title (defaults to `SITE_TITLE`) @@ -107,7 +108,8 @@ Note that `API_URL` should be set to the publicly-reachable URL where the user's Elements-only configuration: -- `IS_ELEMENTS` - set to `1` to indicate this is an Elements-based chain (enables asset issuance and peg features) +- `IS_ELEMENTS` - set to `1` to indicate this is an Elements-based chain (enables asset issuance and Elements-specific features) +- `SHOW_PEG_DATA` - set to `1` to show dashboard peg data and fetch its API resources (enabled by the Liquid mainnet and regtest flavors; custom pegged chains must opt in) - `NATIVE_ASSET_ID` - the ID of the native asset used to pay fees (defaults to `6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d`, the asset id for BTC) - `BLIND_PREFIX` - the base58 address prefix byte used for confidential addresses (defaults to `12`) - `PARENT_CHAIN_EXPLORER_TXOUT` - URL format for linking to transaction outputs on the parent chain, with `{txid}` and `{vout}` as placeholders. Example: `https://blockstream.info/tx/{txid}#output:{vout}` diff --git a/client/src/app.js b/client/src/app.js index a6d6f89e3..520f22f19 100644 --- a/client/src/app.js +++ b/client/src/app.js @@ -5,7 +5,7 @@ import {setAdapt} from '@cycle/run/lib/adapt'; import { getMempoolDepth, getConfEstimate, calcSegwitFeeGains } from './lib/fees' import { isBitcoinNetwork } from './lib/network' import getPrivacyAnalysis from './lib/privacy-analysis' -import { nativeAssetId, blockTxsPerPage, blocksPerPage, difficultyPeriod } from './const' +import { nativeAssetId, blockTxsPerPage, blocksPerPage, difficultyPeriod, showPegData } from './const' import { dbg, combine, @@ -76,6 +76,14 @@ export default function main({ DOM, HTTP, route, storage, scanner: scan$, search const reply = (cat, raw) => dropErrors(HTTP.select(cat)).map(r => raw ? r : (r.body || r.text)) + , recoverableReply = cat => O.merge( + reply(cat).map(value => ({ value, succeeded: true })) + , extractErrors(HTTP.select(cat)).mapTo({ succeeded: false })) + .scan((state, result) => result.succeeded + ? { value: result.value, error: false } + : { ...state, error: true } + , { value: null, error: false }) + .startWith({ value: null, error: false }) , on = (sel, ev, opt={}) => DOM.select(sel).events(ev, opt) , click = sel => on(sel, 'click').map(e => e.ownerTarget.dataset) @@ -245,8 +253,28 @@ export default function main({ DOM, HTTP, route, storage, scanner: scan$, search , newTxEntries$ = trackNewEntries(mempoolRecent$, tx => tx.txid) // dashboard - , dashboardState$ = O.combineLatest(blocks$, mempoolRecent$, (blks, txs) => - ({ dashblocks: blks.slice(0, 5), dashTxs: txs.slice(0, 5)})) + , dashboardPegAsset$ = !showPegData + ? O.of({ value: null, error: false }) + : recoverableReply('dashboard-peg-asset') + , dashboardPegChainTxs$ = !showPegData + ? O.of({ value: null, error: false }) + : recoverableReply('dashboard-peg-chain-txs') + , dashboardPegMempoolTxs$ = !showPegData + ? O.of({ value: null, error: false }) + : recoverableReply('dashboard-peg-mempool-txs') + , dashboardPegState$ = O.combineLatest( + dashboardPegAsset$ + , dashboardPegChainTxs$ + , dashboardPegMempoolTxs$ + , (asset, chainTxs, mempoolTxs) => ({ + asset: asset.value + , txs: chainTxs.value != null && mempoolTxs.value != null + ? [ ...mempoolTxs.value, ...chainTxs.value ] + : null + , error: asset.error || chainTxs.error || mempoolTxs.error + })) + , dashboardState$ = O.combineLatest(blocks$, mempoolRecent$, dashboardPegState$, (blks, txs, peg) => + ({ dashblocks: blks.slice(0, 5), dashTxs: txs.slice(0, 11), peg })) , dashboardEpochStartBlock$ = reply('dashboard-epoch-start-block', true) .map(r => ({ ...r.body, requestedHeight: r.request.height })) @@ -455,13 +483,23 @@ export default function main({ DOM, HTTP, route, storage, scanner: scan$, search , tickWhileViewing(60000, 'dashBoard', view$) .flatMap(_ => [{ category: 'fee-est', method: 'GET', path: '/fee-estimates', bg: true } , { category: 'mempool', method: 'GET', path: '/mempool', bg: true } - , { category: 'bitcoin-market-chart', method: 'GET', path: bitcoinMarketChartUrl, bg: true }]) + , { category: 'bitcoin-market-chart', method: 'GET', path: bitcoinMarketChartUrl, bg: true }] + .concat(!showPegData ? [] : + [{ category: 'dashboard-peg-asset', method: 'GET', path: `/asset/${nativeAssetId}`, bg: true } + , { 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 }])) , goHome$.flatMap(_ => [{ category: 'blocks', method: 'GET', path: '/blocks' } , { category: 'recent', method: 'GET', path: '/mempool/recent' } , { category: 'fee-est', method: 'GET', path: '/fee-estimates' } , { category: 'mempool', method: 'GET', path: '/mempool' } , { category: 'bitcoin-market-chart', method: 'GET', path: bitcoinMarketChartUrl, bg: true }]) + + // fetch peg data only when opening an Elements dashboard + , !showPegData ? O.empty() : + goHome$.flatMap(_ => [{ category: 'dashboard-peg-asset', method: 'GET', path: `/asset/${nativeAssetId}`, bg: true } + , { 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 }]) // // elements/liquid only // @@ -549,6 +587,17 @@ export default function main({ DOM, HTTP, route, storage, scanner: scan$, search on('.table-copy-button', 'click', { preventDefault: true }).subscribe(e => e.stopPropagation()) on('.tooltip', 'click', { preventDefault: true }).subscribe(e => e.stopPropagation()) + const keepTooltipInViewport = ({ ownerTarget: tooltip }) => { + const dialogue = tooltip.querySelector('.tooltip-dialogue') + if (!dialogue) return + + tooltip.classList.remove('tooltip-flipped') + const bounds = dialogue.getBoundingClientRect() + , viewportWidth = document.documentElement.clientWidth + tooltip.classList.toggle('tooltip-flipped', bounds.left < 0 || bounds.right > viewportWidth) + } + O.merge(on('.tooltip', 'mouseenter'), on('.tooltip', 'focus')).subscribe(keepTooltipInViewport) + on('.toggle-container', 'click').subscribe(({ ownerTarget: burgerMenu }) => { burgerMenu.classList.toggle('open-menu'); }) diff --git a/client/src/const.js b/client/src/const.js index c4dfb0e4f..b173552bb 100644 --- a/client/src/const.js +++ b/client/src/const.js @@ -7,9 +7,15 @@ export const satoshisPerBitcoin = 100000000 export const averageNativeSegwitTransactionSize = 140 export const maxBlockWeight = 4000000 +const configuredTargetBlockIntervalSeconds = Number(process.env.TARGET_BLOCK_INTERVAL_SECONDS) +export const targetBlockIntervalSeconds = configuredTargetBlockIntervalSeconds > 0 + ? configuredTargetBlockIntervalSeconds + : process.env.IS_ELEMENTS ? 60 : 600 + export const nativeAssetId = process.env.NATIVE_ASSET_ID || '6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d' export const nativeAssetLabel = process.env.NATIVE_ASSET_LABEL || 'BTC' export const nativeAssetName = process.env.NATIVE_ASSET_NAME || 'Bitcoin' +export const showPegData = !!process.env.IS_ELEMENTS && process.env.SHOW_PEG_DATA == '1' // Elements only export const assetTxsPerPage = 25 diff --git a/client/src/lib/peg.js b/client/src/lib/peg.js new file mode 100644 index 000000000..942067078 --- /dev/null +++ b/client/src/lib/peg.js @@ -0,0 +1,27 @@ +const isNonNegativeNumber = value => Number.isFinite(value) && value >= 0 + +export const getPegAccounting = (chainStats = {}) => { + const pegInAmount = chainStats.peg_in_amount + , pegOutAmount = chainStats.peg_out_amount + , burnedAmount = chainStats.burned_amount + , federationAssets = isNonNegativeNumber(pegInAmount) && + isNonNegativeNumber(pegOutAmount) && pegInAmount >= pegOutAmount + ? pegInAmount - pegOutAmount + : null + , circulatingLiabilities = isNonNegativeNumber(federationAssets) && + isNonNegativeNumber(burnedAmount) && federationAssets >= burnedAmount + ? federationAssets - burnedAmount + : null + , assetsVsLiabilitiesRatio = isNonNegativeNumber(federationAssets) && circulatingLiabilities > 0 + ? federationAssets / circulatingLiabilities * 100 + : null + + return { + pegInAmount + , pegOutAmount + , burnedAmount + , federationAssets + , circulatingLiabilities + , assetsVsLiabilitiesRatio + } +} diff --git a/client/src/views/blocks.js b/client/src/views/blocks.js index 4c692d4be..c18bade8a 100644 --- a/client/src/views/blocks.js +++ b/client/src/views/blocks.js @@ -12,7 +12,7 @@ import { Tooltip } from "../components/tooltip"; const staticRoot = process.env.STATIC_ROOT || ""; export const blks = (blocks, viewMore, { t, ...S }) => ( - <div className="block-container"> + <div className="blocks-page"> {!blocks ? ( loader() ) : !blocks.length ? ( diff --git a/client/src/views/home.js b/client/src/views/home.js index 7f5c7073f..877a94c52 100644 --- a/client/src/views/home.js +++ b/client/src/views/home.js @@ -1,9 +1,11 @@ import layout from "./layout"; import { blks } from "./blocks"; import { transactions } from "./transactions"; +import { pegInfo } from "./peg-info"; import { overview } from "./overview"; import difficultyAdjustment from "./difficulty-adjustment"; import { isBitcoinNetwork } from "../lib/network"; +import { showPegData } from "../const"; const isTouch = process.browser && "ontouchstart" in window; @@ -11,13 +13,18 @@ const homeLayout = (body, { t, activeTab, ...S }) => layout(body, { t, isTouch, activeTab, ...S }); export const dashBoard = ({ t, blocks, dashboardState, loading, ...S }) => { - const { dashblocks, dashTxs } = dashboardState || {}; + const { dashblocks, dashTxs, peg = {} } = dashboardState || {}; return homeLayout( - <div key="dashBoard"> + <div className="home-page" key="dashBoard"> {overview({ blocks: dashblocks, ...S })} {blks(dashblocks, true, { t, ...S })} - {transactions(dashTxs, true, { t, ...S })} + <div className="dashboard-transaction-section"> + {transactions(dashTxs, true, { t, ...S })} + {showPegData + ? pegInfo(peg.asset, peg.txs, { t, ...S, error: peg.error }) + : ""} + </div> {isBitcoinNetwork ? difficultyAdjustment({ blocks: dashblocks, ...S }) : ""} diff --git a/client/src/views/overview.js b/client/src/views/overview.js index 98752bb21..a20fe305a 100644 --- a/client/src/views/overview.js +++ b/client/src/views/overview.js @@ -98,7 +98,7 @@ export const overview = ({ return ( <div className="overview"> - <p className="overview-title">Overview</p> + <p className="section-title">Overview</p> <div className="overview-body"> <InfoCard title="Time since last block" diff --git a/client/src/views/peg-info.js b/client/src/views/peg-info.js new file mode 100644 index 000000000..f1cc4b87f --- /dev/null +++ b/client/src/views/peg-info.js @@ -0,0 +1,338 @@ +import { formatSat, formatNumber, formatTime, truncateTxid } from "./util"; +import loader from "../components/loading"; +import { InfoCard } from "../components/info-card"; +import { ArrowsInSimpleIcon } from "../components/icons"; +import { StatusBadge } from "../components/status-badge"; +import { InfoStat } from "../components/info-stat"; +import { Tooltip } from "../components/tooltip"; +import { getConfEstimate } from "../lib/fees"; +import { getPegAccounting } from "../lib/peg"; +import { calculateFeerates } from "../util"; +import { + nativeAssetId, + satoshisPerBitcoin, + targetBlockIntervalSeconds, +} from "../const"; + +const staticRoot = process.env.STATIC_ROOT || ""; +const ratioMiddleBound = 100; +const minimumRatioHalfRange = 0.1; +const ratioScaleIncrement = 0.1; +const ratioScaleHeadroom = 0.05; +const recentPegTransactionLimit = 4; + +const getPegTypes = (tx) => { + const pegTypes = []; + if (tx.vin && tx.vin.some((vin) => vin.is_pegin)) pegTypes.push("peg-in"); + if (tx.vout && tx.vout.some((vout) => vout.pegout)) pegTypes.push("peg-out"); + return pegTypes; +}; + +const sumValues = (outputs) => + outputs.length && outputs.every((output) => Number.isFinite(output.value)) + ? outputs.reduce((sum, output) => sum + output.value, 0) + : null; + +const getPegInAmount = (tx) => { + const outputs = tx.vout || []; + const regularInputs = (tx.vin || []).filter((vin) => !vin.is_pegin); + const hasUnknownOutputAsset = outputs.some( + (output) => output.assetcommitment && !output.asset, + ); + const hasUnknownInput = regularInputs.some( + (vin) => + !vin.prevout || + (vin.prevout.assetcommitment && !vin.prevout.asset) || + (vin.prevout.asset == nativeAssetId && + !Number.isFinite(vin.prevout.value)), + ); + if (hasUnknownOutputAsset || hasUnknownInput) return null; + + const nativeOutputAmount = sumValues( + outputs.filter((output) => output.asset == nativeAssetId), + ); + const nativeInputAmount = regularInputs + .map((vin) => vin.prevout) + .filter((output) => output.asset == nativeAssetId) + .reduce((sum, output) => sum + output.value, 0); + + return Number.isFinite(nativeOutputAmount) && nativeOutputAmount >= nativeInputAmount + ? nativeOutputAmount - nativeInputAmount + : null; +}; + +const getPegAmount = (tx, pegType) => { + const outputs = tx.vout || []; + return pegType == "peg-in" + ? getPegInAmount(tx) + : sumValues(outputs.filter((output) => output.pegout)); +}; + +const getRatioScale = (ratio) => { + const ratioDistance = Number.isFinite(ratio) + ? Math.abs(ratio - ratioMiddleBound) + : 0; + const halfRange = Math.max( + minimumRatioHalfRange, + Math.ceil( + (ratioDistance + ratioScaleHeadroom) / ratioScaleIncrement, + ) * ratioScaleIncrement, + ); + const lowerBound = ratioMiddleBound - halfRange; + const upperBound = ratioMiddleBound + halfRange; + const fill = Number.isFinite(ratio) + ? Math.min( + 100, + Math.max(0, ((ratio - lowerBound) / (upperBound - lowerBound)) * 100), + ) + : 0; + + return { lowerBound, upperBound, fill }; +}; + +const formatRatioBound = (ratio) => `${ratio.toFixed(1)}%`; + +const getLastConfirmationTime = (txs) => + txs.reduce( + (latest, tx) => + tx.status && Number.isFinite(tx.status.block_time) + ? Math.max(latest, tx.status.block_time) + : latest, + 0, + ); + +const formatStatAmount = (value, t) => + Number.isFinite(value) ? formatSat(value) : t`N/A`; + +const formatFederationAssets = (value, t) => + Number.isFinite(value) ? formatSat(value, "BTC") : t`N/A`; + +const formatVolumeAmount = (value, t) => { + if (!Number.isFinite(value)) return t`N/A`; + + const amount = (value / satoshisPerBitcoin).toFixed(2); + return `~${formatNumber(amount)}`; +}; + +const getConfirmationEta = (tx, feeEst, t) => { + if (!tx.status) return t`N/A`; + if (tx.status.confirmed) return t`Confirmed`; + if (!feeEst) return t`N/A`; + + const { effectiveFeerate } = calculateFeerates(tx, null, feeEst); + if (effectiveFeerate == null) return t`N/A`; + + const confirmationBlocks = getConfEstimate(feeEst, effectiveFeerate); + if (confirmationBlocks == -1) return t`Unknown`; + + const confirmationMinutes = + Number(confirmationBlocks) * targetBlockIntervalSeconds / 60; + return confirmationMinutes < 1 + ? t`< 1 min` + : t`~${Math.ceil(confirmationMinutes)} min`; +}; + +export const pegInfo = (asset, txs, { t, feeEst, error }) => { + if (error && (!asset || !txs)) { + return ( + <div className="tx-container"> + <div className="table peg-info-unavailable"> + <div className="table-header"> + <div className="table-header-icon-container"> + <ArrowsInSimpleIcon /> + </div> + <h1 className="table-header-title">{t`Peg Information`}</h1> + </div> + <p>{t`Peg data is currently unavailable.`}</p> + </div> + </div> + ); + } + if (!asset || !txs) return <div className="tx-container">{loader()}</div>; + + const chainStats = asset.chain_stats || {}; + const { + pegInAmount, + pegOutAmount, + federationAssets, + assetsVsLiabilitiesRatio, + } = getPegAccounting(chainStats); + const ratioScale = getRatioScale(assetsVsLiabilitiesRatio); + const allPegTransactions = txs + .reduce( + (entries, tx) => entries.concat( + getPegTypes(tx).map((pegType) => ({ tx, pegType })), + ), + [], + ) + .sort((a, b) => { + const aConfirmed = a.tx.status && a.tx.status.confirmed; + const bConfirmed = b.tx.status && b.tx.status.confirmed; + return aConfirmed != bConfirmed + ? aConfirmed ? 1 : -1 + : ((b.tx.status && b.tx.status.block_time) || 0) - + ((a.tx.status && a.tx.status.block_time) || 0); + }); + const pegTransactions = allPegTransactions.slice(0, recentPegTransactionLimit); + const lastConfirmationTime = getLastConfirmationTime( + allPegTransactions.map(({ tx }) => tx), + ); + + return ( + <div> + <p className="section-title">{t`Proof of Reserves`}</p> + <div className="peg-info"> + {error ? ( + <p className="peg-info-stale" role="status"> + {t`Unable to refresh — showing previous data.`} + </p> + ) : null} + <InfoCard + title={t`Federation BTC Holdings`} + className="federation-btc-holdings" + tooltip={{ + iconSrc: `${staticRoot}img/icons/tooltip.svg`, + text: t`Confirmed peg-ins minus confirmed peg-outs.`, + }} + value={formatFederationAssets(federationAssets, t)} + footer={ + lastConfirmationTime + ? t`Last change on ${formatTime(lastConfirmationTime, false)}` + : t`Last change N/A` + } + /> + + <InfoCard + title={t`Assets vs Liabilities`} + className="assets-vs-liabilities" + tooltip={{ + iconSrc: `${staticRoot}img/icons/tooltip.svg`, + text: t`Confirmed federation BTC holdings divided by circulating L-BTC supply.`, + }} + body={ + <div className="assets-vs-liabilities-body"> + <div className="assets-vs-liabilities-scale"> + <p>{formatRatioBound(ratioScale.lowerBound)}</p> + <p>{formatRatioBound(ratioMiddleBound)}</p> + <p>{formatRatioBound(ratioScale.upperBound)}</p> + </div> + <div className="assets-vs-liabilities-bar"> + <div + className="assets-vs-liabilities-fill" + style={{ width: `${ratioScale.fill}%` }} + ></div> + </div> + <p className="assets-vs-liabilities-ratio"> + {Number.isFinite(assetsVsLiabilitiesRatio) + ? `${assetsVsLiabilitiesRatio.toFixed(3)}%` + : t`N/A`} + </p> + </div> + } + /> + + <div className="table peg-transaction-table"> + <div className="table-header"> + <div className="table-header-icon-container"> + <ArrowsInSimpleIcon /> + </div> + <h1 className="table-header-title">{t`Recent Peg-Ins/Outs`}</h1> + </div> + + <div className="info-stats-row"> + <InfoStat + title={t`PEG-IN`} + value={ + Number.isFinite(chainStats.peg_in_count) + ? <span className="text-success"> + {formatNumber(chainStats.peg_in_count)} + </span> + : t`N/A` + } + /> + <InfoStat + title={t`PEG-OUT`} + value={ + Number.isFinite(chainStats.peg_out_count) + ? <span className="text-danger"> + {formatNumber(chainStats.peg_out_count)} + </span> + : t`N/A` + } + /> + <InfoStat title={t`VOLUME IN`} value={formatVolumeAmount(pegInAmount, t)} /> + <InfoStat title={t`VOLUME OUT`} value={formatVolumeAmount(pegOutAmount, t)} /> + </div> + + <div className="table-title-row"> + <div className="peg-transaction-table-transaction-type">{t`TYPE`}</div> + <div className="peg-transaction-table-transaction-txid">{t`TXID`}</div> + <div className="peg-transaction-table-transaction-amount">{t`AMOUNT`}</div> + <div className="peg-transaction-table-transaction-block">{t`BLOCK`}</div> + <div className="peg-transaction-table-transaction-eta"> + <span>{t`ETA`}</span> + <Tooltip + iconSrc={`${staticRoot}img/icons/tooltip.svg`} + text={t`Estimated time until the peg transaction is confirmed.`} + /> + </div> + </div> + + <div className="peg-transaction-table-body"> + {!pegTransactions.length ? ( + <p>{t`No recent transactions`}</p> + ) : ( + pegTransactions.map(({ tx, pegType }) => ( + <a key={`${tx.txid}-${pegType}`} href={`tx/${tx.txid}`}> + <div className="transaction-table-row"> + <div className="transaction-table-field peg-transaction-table-transaction-type"> + <div className="transaction-table-field-label">{t`TYPE`}</div> + <div className="transaction-table-field-value"> + <StatusBadge + variant={pegType == "peg-in" ? "success" : "danger"} + className={ + pegType == "peg-in" + ? "peg-transaction-table-pegin-badge" + : null + } + > + {pegType == "peg-in" ? t`Peg-in` : t`Peg-out`} + </StatusBadge> + </div> + </div> + <div className="transaction-table-field peg-transaction-table-transaction-txid"> + <div className="transaction-table-field-label">{t`TXID`}</div> + <div className="transaction-table-field-value"> + <p>{truncateTxid(tx.txid)}</p> + </div> + </div> + <div className="transaction-table-field peg-transaction-table-transaction-amount"> + <div className="transaction-table-field-label">{t`AMOUNT`}</div> + <div className="transaction-table-field-value"> + {formatStatAmount(getPegAmount(tx, pegType), t)} + </div> + </div> + <div className="transaction-table-field peg-transaction-table-transaction-block"> + <div className="transaction-table-field-label">{t`BLOCK`}</div> + <div className="transaction-table-field-value"> + {tx.status && Number.isFinite(tx.status.block_height) + ? `#${formatNumber(tx.status.block_height)}` + : t`N/A`} + </div> + </div> + <div className="transaction-table-field peg-transaction-table-transaction-eta"> + <div className="transaction-table-field-label">{t`ETA`}</div> + <div className="transaction-table-field-value peg-transaction-eta-badge"> + {getConfirmationEta(tx, feeEst, t)} + </div> + </div> + </div> + </a> + )) + )} + </div> + </div> + </div> + </div> + ); +}; diff --git a/client/src/views/transactions.js b/client/src/views/transactions.js index bc661c502..eebf48e78 100644 --- a/client/src/views/transactions.js +++ b/client/src/views/transactions.js @@ -16,7 +16,7 @@ const feeRateClass = (feerate, feeEst) => { } export const transactions = (txs, viewMore, { t, ...S }) => ( - <div className="tx-container"> + <div className="txs-page"> {!txs ? ( loader() ) : !txs.length ? ( @@ -30,7 +30,7 @@ export const transactions = (txs, viewMore, { t, ...S }) => ( <h1 className="table-header-title">Latest Transactions</h1> </div> - <div className="table-title-row"> + <div className="table-title-row latest-transactions-table-title-row"> <div className="transaction-table-transaction-id">TRANSACTION ID</div> <div className="transaction-table-transaction-value">VALUE</div> <div className="transaction-table-transaction-size">SIZE</div> @@ -83,12 +83,14 @@ export const transactions = (txs, viewMore, { t, ...S }) => ( </div> {txs && viewMore ? ( - <a className="view-more font-link-semibold" href="tx/recent"> - <span>{t`See more`}</span> - <div> - <img alt="" src={`${staticRoot}img/icons/arrow-right-blue.svg`} /> - </div> - </a> + <div className="transaction-table-view-more-container"> + <a className="view-more font-link-semibold" href="tx/recent"> + <span>{t`See more`}</span> + <div> + <img alt="" src={`${staticRoot}img/icons/arrow-right-blue.svg`} /> + </div> + </a> + </div> ) : ( "" )} diff --git a/client/src/views/tx.js b/client/src/views/tx.js index 42b8f6de4..b726b339e 100644 --- a/client/src/views/tx.js +++ b/client/src/views/tx.js @@ -23,6 +23,7 @@ import { InfoStat } from "../components/info-stat"; import { StatusBadge } from "../components/status-badge"; import { Tooltip } from "../components/tooltip"; import BlockDetailsCard from "../components/block-details-card"; +import { targetBlockIntervalSeconds } from "../const"; // Require behind env conditional so it gets removed by `envify` on non-elements builds const deduceBlinded = @@ -241,12 +242,11 @@ const txHeader = ( ) => { 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`; + : `~${Math.ceil(confEstimate * targetBlockIntervalSeconds / 60)} min`; const confirmationTime = isConfirmed && Number.isFinite(tx.status.block_time) ? formatTime(tx.status.block_time) diff --git a/flavors/liquid-mainnet/config.env b/flavors/liquid-mainnet/config.env index 97bb2e0c3..58441c4d5 100755 --- a/flavors/liquid-mainnet/config.env +++ b/flavors/liquid-mainnet/config.env @@ -6,6 +6,7 @@ export NATIVE_ASSET_ID="6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec export NATIVE_ASSET_LABEL=LBTC export NATIVE_ASSET_NAME='Liquid Bitcoin' export IS_ELEMENTS=1 +export SHOW_PEG_DATA=${SHOW_PEG_DATA:-1} export ASSET_MAP_URL=./_data/assets.minimal.json diff --git a/flavors/liquid-regtest/config.env b/flavors/liquid-regtest/config.env index 5253aa3b3..7f84bfde0 100644 --- a/flavors/liquid-regtest/config.env +++ b/flavors/liquid-regtest/config.env @@ -7,10 +7,10 @@ export NATIVE_ASSET_LABEL=rLBTC export NATIVE_ASSET_NAME="Liquid Regtest Bitcoin" export IS_ELEMENTS=1 +export SHOW_PEG_DATA=${SHOW_PEG_DATA:-1} export MENU_ACTIVE='Liquid Regtest' export BASE_HREF=${BASE_HREF:-'/liquidregtest/'} export CUSTOM_ASSETS="$CUSTOM_ASSETS flavors/liquid-regtest/www/*" export CUSTOM_CSS="$CUSTOM_CSS flavors/liquid/extras.css flavors/bitcoin-testnet/extras.css" - diff --git a/flavors/liquid-testnet/config.env b/flavors/liquid-testnet/config.env index e40853673..a5cfa12d9 100644 --- a/flavors/liquid-testnet/config.env +++ b/flavors/liquid-testnet/config.env @@ -7,6 +7,7 @@ export NATIVE_ASSET_LABEL=tLBTC export NATIVE_ASSET_NAME="Liquid Testnet Bitcoin" export IS_ELEMENTS=1 +export SHOW_PEG_DATA=${SHOW_PEG_DATA:-0} export ASSET_MAP_URL=./_data/assets.minimal.json export MENU_ACTIVE='Liquid Testnet' diff --git a/flavors/liquid/extras.css b/flavors/liquid/extras.css index 4ff4b9472..910eb17f4 100644 --- a/flavors/liquid/extras.css +++ b/flavors/liquid/extras.css @@ -78,7 +78,6 @@ /******* ASSET TABLE *******/ .asset-container { - margin-top: 40px; background-color: var(--surface-primary-color); padding: 24px; border-radius: 12px; diff --git a/lang/strings.txt b/lang/strings.txt index f65fbfbea..a1f47cff9 100644 --- a/lang/strings.txt +++ b/lang/strings.txt @@ -1,6 +1,7 @@ Address Address reuse Address: %s +AMOUNT Amount commitment API Asset @@ -8,7 +9,9 @@ Asset commitment Asset ID Asset name Asset: %s +Assets vs Liabilities Bits +BLOCK Block Challenge Block height Block not found @@ -25,7 +28,9 @@ CoinJoin transactions hide the link between inputs and outputs and improves Bitc compared to bitcoind's suggested fee of %s sat/vB for confirmation within 2 blocks Confidential Confirmed +Confirmed federation BTC holdings divided by circulating L-BTC supply. Confirmed non-confidential tx count +Confirmed peg-ins minus confirmed peg-outs. Confirmed received Confirmed spent Confirmed tx count @@ -38,7 +43,9 @@ Description Details Esplora is currently unavailable, please try again later. ETA +Estimated time until the peg transaction is confirmed. Fee +Federation BTC Holdings Go Height In best chain @@ -55,6 +62,8 @@ Issued asset id It is possible to tell the change output apart because you're sending to a different script type than the one you're spending from. lang_id lang_name +Last change N/A +Last change on %s Likely self-transfer Linked domain Loading... @@ -63,6 +72,7 @@ Lock time ltr Mempool Merkle root +N/A New asset Newer Next @@ -86,6 +96,11 @@ overpaying by %s% P2SH redeem script P2WSH witness script Page Not Found +PEG-IN +PEG-OUT +Peg Information +Peg data is currently unavailable. +Peg-in Peg-out Peg-out address Peg-out ASM @@ -97,7 +112,9 @@ Previous Previous output address Previous output script Privacy analysis +Proof of Reserves (RBF) +Recent Peg-Ins/Outs Recent transactions Re-issuable Reissuance @@ -153,8 +170,10 @@ Transaction not found Transactions Transaction: %s TXID +TYPE txid:vout Type +Unable to refresh — showing previous data. unconfirmed Unconfirmed Unconfirmed received @@ -169,8 +188,12 @@ Value Value commitment Version Virtual size +VOLUME IN +VOLUME OUT We encountered an error. Please try again later. Weight (KWU) Weight units Witness Yes +< 1 min +~%s min diff --git a/test/peg.test.js b/test/peg.test.js new file mode 100644 index 000000000..a5dac610e --- /dev/null +++ b/test/peg.test.js @@ -0,0 +1,261 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const render = require("snabbdom-to-html"); + +const { getPegAccounting } = require("../client/src/lib/peg"); +const { pegInfo } = require("../client/src/views/peg-info"); +const l10n = require("../client/src/l10n").default; +const { nativeAssetId } = require("../client/src/const"); + +test("derives federation assets and circulating liabilities from distinct peg fields", () => { + const accounting = getPegAccounting({ + peg_in_amount: 100_000_000_000, + peg_out_amount: 10_000_000_000, + burned_amount: 1_000_000_000, + }); + + assert.equal(accounting.federationAssets, 90_000_000_000); + assert.equal(accounting.circulatingLiabilities, 89_000_000_000); + assert.equal( + accounting.assetsVsLiabilitiesRatio, + 90_000_000_000 / 89_000_000_000 * 100, + ); +}); + +test("keeps burns separate from peg-outs and handles invalid ratios", () => { + const withoutBurns = getPegAccounting({ + peg_in_amount: 1000, + peg_out_amount: 100, + burned_amount: 0, + }); + const withoutLiabilities = getPegAccounting({ + peg_in_amount: 1000, + peg_out_amount: 100, + burned_amount: 900, + }); + const invalidAssets = getPegAccounting({ + peg_in_amount: 100, + peg_out_amount: 101, + burned_amount: 0, + }); + const incompleteStats = getPegAccounting({ + peg_in_amount: 100, + peg_out_amount: 10, + }); + + assert.equal(withoutBurns.pegOutAmount, 100); + assert.equal(withoutBurns.federationAssets, 900); + assert.equal(withoutBurns.circulatingLiabilities, 900); + assert.equal(withoutBurns.assetsVsLiabilitiesRatio, 100); + assert.equal(withoutLiabilities.assetsVsLiabilitiesRatio, null); + assert.equal(invalidAssets.federationAssets, null); + assert.equal(invalidAssets.circulatingLiabilities, null); + assert.equal(invalidAssets.assetsVsLiabilitiesRatio, null); + assert.equal(incompleteStats.federationAssets, 90); + assert.equal(incompleteStats.circulatingLiabilities, null); + assert.equal(incompleteStats.assetsVsLiabilitiesRatio, null); +}); + +test("renders holdings in BTC and aggregate volumes with two decimal places", () => { + const html = render(pegInfo({ + chain_stats: { + peg_in_count: 10, + peg_in_amount: 100_000_000_000, + peg_out_count: 4, + peg_out_amount: 10_000_000_000, + burned_amount: 1_000_000_000, + }, + }, [], { t: l10n.en, feeEst: null })); + + assert.match(html, />900\.00000000 BTC</); + assert.match(html, />101\.124%<\/p>/); + assert.match(html, /VOLUME IN<\/div><div class="info-stat-value">~1,000\.00</); + assert.match(html, /VOLUME OUT<\/div><div class="info-stat-value">~100\.00</); +}); + +test("passes peg information copy through localization", () => { + const localizedStrings = new Set(); + const t = (parts, ...values) => { + const key = parts.join("%s"); + localizedStrings.add(key); + return parts.reduce( + (result, part, index) => result + part + (values[index] || ""), + "", + ); + }; + const txs = [{ + txid: "a".repeat(64), + vin: [{ is_pegin: true }], + vout: [{ asset: nativeAssetId, value: 600 }], + status: { confirmed: true, block_height: 100, block_time: 1000 }, + }, { + txid: "b".repeat(64), + vin: [], + vout: [{ asset: nativeAssetId, value: 400, pegout: {} }], + status: { confirmed: false }, + }]; + + render(pegInfo({ + chain_stats: { + peg_in_count: 1, + peg_in_amount: 600, + peg_out_count: 1, + peg_out_amount: 400, + burned_amount: 0, + }, + }, txs, { t, feeEst: null })); + + [ + "Proof of Reserves", + "Federation BTC Holdings", + "Confirmed peg-ins minus confirmed peg-outs.", + "Last change on %s", + "Assets vs Liabilities", + "Confirmed federation BTC holdings divided by circulating L-BTC supply.", + "Recent Peg-Ins/Outs", + "PEG-IN", + "PEG-OUT", + "VOLUME IN", + "VOLUME OUT", + "TYPE", + "TXID", + "AMOUNT", + "BLOCK", + "ETA", + "Estimated time until the peg transaction is confirmed.", + "Peg-in", + "Peg-out", + "Confirmed", + "N/A", + ].forEach((message) => assert.ok( + localizedStrings.has(message), + `Missing localized string: ${message}`, + )); +}); + +test("expands a transaction containing both peg directions into two event rows", () => { + const txid = "a".repeat(64); + const html = render(pegInfo({ + chain_stats: { + peg_in_count: 1, + peg_in_amount: 1010, + peg_out_count: 1, + peg_out_amount: 400, + burned_amount: 0, + }, + }, [{ + txid, + vin: [{ is_pegin: true }], + vout: [ + { asset: nativeAssetId, value: 600 }, + { asset: nativeAssetId, value: 400, pegout: {} }, + { asset: nativeAssetId, value: 10, scriptpubkey_type: "fee" }, + ], + status: { confirmed: true, block_height: 100, block_time: 1000 }, + }], { t: l10n.en, feeEst: null })); + + assert.equal((html.match(new RegExp(`href="tx/${txid}"`, "g")) || []).length, 2); + assert.match(html, /Peg-in/); + assert.match(html, /Peg-out/); + assert.match(html, /0\.00001010 BTC/); + assert.match(html, /0\.00000400 BTC/); +}); + +test("subtracts regular native inputs when deriving a peg-in amount", () => { + const html = render(pegInfo({ + chain_stats: { + peg_in_count: 1, + peg_in_amount: 610, + peg_out_count: 0, + peg_out_amount: 0, + burned_amount: 0, + }, + }, [{ + txid: "b".repeat(64), + vin: [ + { is_pegin: true }, + { + is_pegin: false, + prevout: { asset: nativeAssetId, value: 400 }, + }, + ], + vout: [ + { asset: nativeAssetId, value: 1000 }, + { asset: nativeAssetId, value: 10, scriptpubkey_type: "fee" }, + ], + status: { confirmed: true, block_height: 100, block_time: 1000 }, + }], { t: l10n.en, feeEst: null })); + + assert.match(html, /0\.00000610 BTC/); +}); + +test("shows at most four recent peg transactions", () => { + const txs = Array.from({ length: 5 }, (_, index) => ({ + txid: String(index + 1).repeat(64), + vin: [{ is_pegin: true }], + vout: [{ asset: nativeAssetId, value: 100 + index }], + status: { + confirmed: true, + block_height: 100 + index, + block_time: 1000 + index, + }, + })); + const html = render(pegInfo({ + chain_stats: { + peg_in_count: 5, + peg_in_amount: 510, + peg_out_count: 0, + peg_out_amount: 0, + burned_amount: 0, + }, + }, txs, { t: l10n.en, feeEst: null })); + + assert.equal((html.match(/href="tx\//g) || []).length, 4); + assert.doesNotMatch(html, new RegExp(`href="tx/${txs[0].txid}"`)); +}); + +test("keeps the ratio within a parity-centered gauge with headroom", () => { + const html = render(pegInfo({ + chain_stats: { + peg_in_count: 1, + peg_in_amount: 100_000_000_000, + peg_out_count: 1, + peg_out_amount: 50_000_000_000, + burned_amount: 100_000_000, + }, + }, [], { t: l10n.en, feeEst: null })); + + assert.match(html, /99\.7%<\/p><p>100\.0%<\/p><p>100\.3%/); + assert.doesNotMatch(html, /assets-vs-liabilities-fill" style="width: 100%/); +}); + +test("renders an unavailable state when peg requests fail", () => { + const html = render(pegInfo(null, null, { + t: l10n.en, + feeEst: null, + error: true, + })); + + assert.match(html, /Peg data is currently unavailable\./); + assert.doesNotMatch(html, /loading/); +}); + +test("keeps previous peg data visible when a refresh fails", () => { + const html = render(pegInfo({ + chain_stats: { + peg_in_count: 10, + peg_in_amount: 100_000_000_000, + peg_out_count: 4, + peg_out_amount: 10_000_000_000, + burned_amount: 1_000_000_000, + }, + }, [], { + t: l10n.en, + feeEst: null, + error: true, + })); + + assert.match(html, /Unable to refresh — showing previous data\./); + assert.match(html, />900\.00000000 BTC</); + assert.doesNotMatch(html, /Peg data is currently unavailable\./); +}); diff --git a/www/style.css b/www/style.css index 9c97a4b39..698639f9f 100644 --- a/www/style.css +++ b/www/style.css @@ -589,6 +589,7 @@ a, a:link, a:visited, a:hover, a:focus { flex-direction: column; flex: 1 0 auto; padding-bottom: 100px; + margin-top: var(--page-gap); } .nav-container{ @@ -1025,18 +1026,20 @@ table th { width: 30px; } -.asset-page, .mempool-page{ +.mempool-page { margin-top: var(--page-gap); } .addr-page, .asset-page, +.blocks-page, .block-page, +.home-page, +.txs-page, .tx-page { display: flex; flex-direction: column; gap: var(--page-gap); - margin-top: var(--page-gap); } .prev-next-blocks-btns { @@ -1223,6 +1226,10 @@ table th { gap: var(--page-gap); } +.block-page-container > .transactions { + margin-top: 0; +} + .transactions > h3, .transactions > img { display: inline-block; } @@ -2872,6 +2879,10 @@ a.back-link img{ padding: 30px; } +.transaction-table-view-more-container { + margin-top: auto; +} + .view-more { display: flex; width: 100%; @@ -2880,7 +2891,7 @@ a.back-link img{ font-family: "Rigid Square"; } -.view-more img{ +.view-more img { margin-left: 11px; width: 18px; } @@ -2981,7 +2992,7 @@ a.back-link img{ .hero-wrapper { display: flex; - margin-top: 90px; + margin-top: 75px; } @media screen and (max-width: 820px) { @@ -3500,6 +3511,13 @@ a.back-link img{ z-index: 999; } +@media only screen and (min-width: 1329px) { + .tooltip.tooltip-flipped .tooltip-dialogue { + right: 15px; + left: auto; + } +} + @keyframes tooltip-mobile-enter { from { opacity: 0; @@ -3705,6 +3723,9 @@ a.back-link img{ background-color: var(--surface-primary-color); padding: 24px; border-radius: 12px; + display: flex; + flex-direction: column; + height: 100%; } .latest-transactions-table-body { @@ -3738,11 +3759,17 @@ a.back-link img{ .transaction-table-transaction-value { flex: 1; +} + +.transaction-table-row .transaction-table-transaction-value { color: white; } .transaction-table-transaction-size { flex: .5; +} + +.transaction-table-row .transaction-table-transaction-size { color: white; } @@ -3761,6 +3788,7 @@ a.back-link img{ font-size: 14px; font-weight: 600; transition: background-color .3s; + gap: 12px; } .transaction-table-field-label { @@ -3871,23 +3899,23 @@ a.back-link img{ margin-top: 12px; font-size: 10px; padding: 0px 12px; + gap: 12px; } .table-title-row a { color: white !important; } +.latest-transactions-table-title-row { + color: var(--foreground-muted); +} + .new-table-entry { background-color: var(--surface-primary-alt-color); border: 1px solid var(--surface-primary-alt-color); } -.block-container, .tx-container { - margin-top: var(--page-gap); -} - .overview { - margin-top: var(--page-gap); display: flex; flex-direction: column; gap: 12px; @@ -3955,6 +3983,11 @@ a.back-link img{ color: var(--success-color); } +.status-badge.danger { + background-color: rgba(var(--danger-color-rgb), .2); + color: var(--danger-color); +} + .eta-label { height: fit-content; border-radius: 4px; @@ -3981,7 +4014,7 @@ a.back-link img{ gap: 12px; } -.overview-title { +.section-title { font-weight: 700; font-size: 14px; font-family: "Rigid Square"; @@ -4477,10 +4510,153 @@ a.back-link img{ gap: 12px; } +.dashboard-transaction-section { + display: flex; + gap: 12px; +} + +.dashboard-transaction-section > div { + flex: 1; + min-width: 0; +} + +.peg-info { + display: flex; + flex-direction: column; + gap: var(--page-gap); + margin-top: 12px; +} + +.peg-info-stale { + margin: 0; + padding: 10px 12px; + border: 1px solid rgba(var(--warning-color-rgb), .4); + border-radius: 4px; + background-color: rgba(var(--warning-color-rgb), .1); + color: var(--warning-color); + font-size: 12px; +} + +.peg-info-unavailable { + display: flex; + flex-direction: column; + gap: 12px; + height: 100%; +} + +.federation-btc-holdings { + width: 100%; +} + +.assets-vs-liabilities { + width: 100%; + height: auto; +} + +.assets-vs-liabilities-body { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + margin-top: 13px; + gap: 6px; +} + +.assets-vs-liabilities-scale { + display: flex; + justify-content: space-between; + font-size: 14px; + font-weight: 600; + width: 100%; +} + +.assets-vs-liabilities-bar { + width: 100%; + height: 6px; + border-radius: 9999px; + background-color: var(--surface-secondary-color); +} + +.assets-vs-liabilities-fill { + height: 100%; + background-color: var(--accent-color); + border-radius: 9999px; +} + +.assets-vs-liabilities-ratio { + font-size: 20px; + font-weight: 700; + font-family: "Rigid Square"; +} + +.peg-transaction-table { + display: flex; + flex-direction: column; + gap: 12px; + height: 100%; +} + +.peg-transaction-table-body { + display: flex; + flex-direction: column; + gap: 12px; +} + .asset-page > .transactions { margin-top: 0; } +.peg-transaction-table .info-stats-row { + display: flex; + justify-content: space-between; +} + +.peg-transaction-table .info-stat { + flex: 1; + align-items: center; +} + +.peg-transaction-table-transaction-type { + flex: .75; + color: white; +} + +.peg-transaction-table-transaction-txid, +.peg-transaction-table-transaction-amount { + flex: 1; + color: white; +} + +.peg-transaction-table-transaction-block { + flex: .5; + color: white; +} + +.peg-transaction-table-transaction-eta { + flex: .5; + color: white; + display: flex; + align-items: center; + justify-content: right; + gap: 4px; + text-align: right; +} + +.peg-transaction-table-pegin-badge { + font-weight: 400; +} + +.peg-transaction-eta-badge { + display: flex; + align-items: center; + justify-content: center; + background-color: var(--surface-primary-color); + font-size: 11px; + font-weight: 400; + padding: 2px 8px; + border-radius: var(--max-border-radius); +} + @media only screen and (max-width: 820px) { .asset-table { align-items: flex-start; @@ -4786,6 +4962,12 @@ a.back-link img{ } } +@media only screen and (max-width: 1328px) { + .dashboard-transaction-section { + flex-direction: column; + } +} + @media only screen and (max-width: 1328px) and (prefers-reduced-motion: reduce) { .tooltip-dialogue { animation: none;