diff --git a/client/src/const.js b/client/src/const.js index fe1d2ade..8f75dfe5 100644 --- a/client/src/const.js +++ b/client/src/const.js @@ -4,10 +4,20 @@ export const blocksPerPage = 10 export const difficultyPeriod = 2016 export const maxMempoolTxs = 50 export const satoshisPerBitcoin = 100000000 -export const averageNativeSegwitTransactionSize = 140 +export const typicalNativeSegwitTransactionVsize = 140 +// One native-SegWit input, two confidential outputs, and an explicit fee +// output. After ELIP-200 normalizes confidential values, nonces, and proofs, +// its discount weight is 228 base-equivalent bytes * 4 plus 117 witness bytes +// (using a 108-byte P2WPKH witness), rounded up from 1029 WU. +export const typicalDiscountedConfidentialTransactionVsize = 258 export const maxBlockWeight = 4000000 export const blockGridLoadingDelayMs = 100 export const blockGridTransactionSelectEvent = 'block-grid-transaction-select' +export const feeEstimateTargets = { + low: 12, + average: 3, + high: 1, +} const configuredTargetBlockIntervalSeconds = Number(process.env.TARGET_BLOCK_INTERVAL_SECONDS) export const targetBlockIntervalSeconds = configuredTargetBlockIntervalSeconds > 0 diff --git a/client/src/lib/block-template.js b/client/src/lib/block-template.js index 55e12c66..d51cab3f 100644 --- a/client/src/lib/block-template.js +++ b/client/src/lib/block-template.js @@ -1,4 +1,4 @@ -import { maxBlockWeight } from "../const"; +import { feeEstimateTargets, maxBlockWeight } from "../const"; import { feeRateClass } from "./fees"; import { clamp } from "./math"; @@ -47,7 +47,11 @@ const summarizeFeeBucket = (transactions) => { }; const summarizeFeeBuckets = (transactions, feeEst) => { - if (!feeEst || feeEst[3] == null || feeEst[12] == null) { + if ( + !feeEst || + feeEst[feeEstimateTargets.average] == null || + feeEst[feeEstimateTargets.low] == null + ) { return { low: null, medium: null, high: null }; } diff --git a/client/src/lib/fees.js b/client/src/lib/fees.js index ba41c689..c8da6203 100644 --- a/client/src/lib/fees.js +++ b/client/src/lib/fees.js @@ -1,11 +1,22 @@ +import { + feeEstimateTargets, + satoshisPerBitcoin, + typicalDiscountedConfidentialTransactionVsize, + typicalNativeSegwitTransactionVsize, +} from '../const' + const MAX_BLOCK_VSIZE = 1000000 + , typicalTransactionVsize = process.env.IS_ELEMENTS + ? typicalDiscountedConfidentialTransactionVsize + : typicalNativeSegwitTransactionVsize export const getFeeTierBoundaries = feeEst => { - const low = feeEst && feeEst[12] - , high = feeEst && feeEst[3] + const lowMaximum = feeEst && feeEst[feeEstimateTargets.low] + , mediumMaximum = feeEst && feeEst[feeEstimateTargets.average] - return Number.isFinite(low) && Number.isFinite(high) && low >= 0 && high >= 0 - ? { low, high: Math.max(low, high) } + return Number.isFinite(lowMaximum) && Number.isFinite(mediumMaximum) + && lowMaximum >= 0 && mediumMaximum >= 0 + ? { lowMaximum, mediumMaximum: Math.max(lowMaximum, mediumMaximum) } : null } @@ -13,9 +24,9 @@ export const feeRateClass = (feerate, feeEst) => { const boundaries = getFeeTierBoundaries(feeEst) if (!Number.isFinite(feerate) || !boundaries) return "" - return feerate <= boundaries.low + return feerate <= boundaries.lowMaximum ? "success" - : feerate <= boundaries.high + : feerate <= boundaries.mediumMaximum ? "warning" : "danger" } @@ -38,6 +49,17 @@ export function getConfEstimate(fee_estimates, feerate) { return target_est ? target_est[0] : -1 } +// Estimate the USD cost of a typical transaction at a given fee-rate. Elements +// uses the ELIP-200 discount virtual size rather than serialized virtual size. +export function estimateTypicalTransactionFeeUsd(bitcoinPrice, feerate) { + return Number.isFinite(bitcoinPrice) && bitcoinPrice >= 0 + && Number.isFinite(feerate) && feerate >= 0 + ? (bitcoinPrice / satoshisPerBitcoin) * + feerate * + typicalTransactionVsize + : null +} + // Squash the fee histogram into fixed feerate ranges export function squashFeeHistogram(histogram) { let i = 0 diff --git a/client/src/lib/market.js b/client/src/lib/market.js new file mode 100644 index 00000000..60155e13 --- /dev/null +++ b/client/src/lib/market.js @@ -0,0 +1,9 @@ +export const getBitcoinPrices = marketChart => + (Array.isArray(marketChart?.prices) ? marketChart.prices : []) + .map(price => Array.isArray(price) ? price[1] : null) + .filter(Number.isFinite) + +export const getLatestBitcoinPrice = marketChart => { + const prices = getBitcoinPrices(marketChart) + return prices.length ? prices[prices.length - 1] : null +} diff --git a/client/src/lib/pending-block-details.js b/client/src/lib/pending-block-details.js index 8b2c8c60..b93a5bab 100644 --- a/client/src/lib/pending-block-details.js +++ b/client/src/lib/pending-block-details.js @@ -47,9 +47,6 @@ const formatTrimmedDecimal = (value) => export const formatPanelPercentage = (value, fallback = "N/A") => Number.isFinite(value) ? `${formatTrimmedDecimal(value)}%` : fallback; -export const formatFeeRate = (value, fallback = "N/A") => - Number.isFinite(value) ? `${value.toFixed(2)} sat/vB` : fallback; - export const formatFeeBoundary = (value, fallback = "N/A") => Number.isFinite(value) ? value.toFixed(2) : fallback; @@ -66,22 +63,6 @@ export const formatMegabytes = (value, fallback = "N/A") => export const formatCount = (value, fallback = "N/A") => Number.isFinite(value) ? value.toLocaleString() : fallback; -export const getLatestBitcoinPrice = (marketChart) => { - const prices = ((marketChart && marketChart.prices) || []) - .map((price) => price && price[1]) - .filter(Number.isFinite); - - return prices.length ? prices[prices.length - 1] : null; -}; - -export const formatUsd = (value, fallback = "N/A") => - Number.isFinite(value) - ? `$${value.toLocaleString("en-US", { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })} USD` - : fallback; - export const formatTransactionDelta = (delta) => delta > 0 ? `+ ${formatCount(delta)}` diff --git a/client/src/views/fee-market.js b/client/src/views/fee-market.js new file mode 100644 index 00000000..580ecb05 --- /dev/null +++ b/client/src/views/fee-market.js @@ -0,0 +1,53 @@ +import { InfoCard } from "../components/info-card"; +import { feeEstimateTargets } from "../const"; +import { estimateTypicalTransactionFeeUsd } from "../lib/fees"; +import { getLatestBitcoinPrice } from "../lib/market"; +import { formatFeeRate, formatUsd } from "./util"; + +const getFeeMarketLevels = (t) => [ + { + key: "low", + title: t`Low`, + tooltip: t`A lower-priority fee rate estimated to confirm within ${feeEstimateTargets.low} blocks.`, + }, + { + key: "average", + title: t`Average`, + tooltip: t`A balanced fee rate estimated to confirm within ${feeEstimateTargets.average} blocks.`, + }, + { + key: "high", + title: t`High`, + tooltip: t`A higher-priority fee rate estimated to confirm in the next block.`, + }, +]; + +export const feeMarket = ({ feeEst, bitcoinMarketChart, t } = {}) => { + const bitcoinPrice = getLatestBitcoinPrice(bitcoinMarketChart); + const unavailable = t`N/A`; + + return ( +
{t`Fee Market`}
+{t`Low`} ( - {`≤${formatFeeBoundary(feeTierBoundaries.low)} sat/vB`} + {`≤${formatFeeBoundary(feeTierBoundaries.lowMaximum)} sat/vB`} )
{t`Medium`} ( - {`>${formatFeeBoundary(feeTierBoundaries.low)}–≤${formatFeeBoundary(feeTierBoundaries.high)} sat/vB`} + {`>${formatFeeBoundary(feeTierBoundaries.lowMaximum)}–≤${formatFeeBoundary(feeTierBoundaries.mediumMaximum)} sat/vB`} )
@@ -532,7 +530,7 @@ const PendingBlockDetailsCard = ({{t`High`} ( - {`>${formatFeeBoundary(feeTierBoundaries.high)} sat/vB`} + {`>${formatFeeBoundary(feeTierBoundaries.mediumMaximum)} sat/vB`} )
diff --git a/client/src/views/util.js b/client/src/views/util.js index b6ca0902..d9dd5b0a 100644 --- a/client/src/views/util.js +++ b/client/src/views/util.js @@ -24,6 +24,20 @@ export const formatTime = (unix, with_tz = true) => { export const formatSat = (sats, label=nativeAssetLabel) => `${formatNumber(sat2btc(sats), NATIVE_PRECISION)} ${label}` +const formatTrimmedDecimal = value => + value.toFixed(2).replace(/\.?0+$/, '') + +export const formatFeeRate = (feerate, fallback='N/A') => + Number.isFinite(feerate) ? `${formatTrimmedDecimal(feerate)} sat/vB` : fallback + +export const formatUsd = (value, fallback='N/A') => + Number.isFinite(value) + ? `$${value.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} USD` + : fallback + export const formatAssetAmount = (value, precision=0, t) => {formatNumber(precision > 0 ? moveDec(value, -precision) : value, precision)} diff --git a/lang/strings.txt b/lang/strings.txt index 40e1b8d6..21f57f14 100644 --- a/lang/strings.txt +++ b/lang/strings.txt @@ -1,3 +1,6 @@ +A balanced fee rate estimated to confirm within %s blocks. +A higher-priority fee rate estimated to confirm in the next block. +A lower-priority fee rate estimated to confirm within %s blocks. Address Address reuse Address: %s @@ -10,6 +13,7 @@ Asset ID Asset name Asset: %s Assets vs Liabilities +Average Bits BLOCK Block Challenge @@ -55,9 +59,11 @@ Esplora is currently unavailable, please try again later. ETA Estimated time until the peg transaction is confirmed. Fee +Fee Market Federation BTC Holdings Go Height +High How full the block is. In block In best chain @@ -81,6 +87,7 @@ Linked domain Loading block... Loading... Load more +Low Lock time ltr Mempool diff --git a/test/fee-market.test.js b/test/fee-market.test.js new file mode 100644 index 00000000..d5de8f2d --- /dev/null +++ b/test/fee-market.test.js @@ -0,0 +1,75 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const render = require("snabbdom-to-html"); + +const { feeMarket } = require("../client/src/views/fee-market"); + +const t = (parts, ...values) => parts.reduce( + (result, part, index) => + result + part + (index < values.length ? values[index] : ""), + "", +); + +test("renders low, average, and high fee estimates with dollar footers", () => { + const html = render(feeMarket({ + feeEst: { + 1: 12, + 3: 6, + 12: 2, + }, + bitcoinMarketChart: { + prices: [[1, 40_000], [2, 50_000], [3, null]], + }, + t, + })); + + const expectedUsd = process.env.IS_ELEMENTS + ? ["$0.26 USD", "$0.77 USD", "$1.55 USD"] + : ["$0.14 USD", "$0.42 USD", "$0.84 USD"]; + + assert.match(html, /Low[\s\S]*2 sat\/vB/); + assert.match(html, /Average[\s\S]*6 sat\/vB/); + assert.match(html, /High[\s\S]*12 sat\/vB/); + expectedUsd.forEach((value) => assert(html.includes(value), value)); + assert.match(html, /within 12 blocks/); + assert.match(html, /within 3 blocks/); + assert.match(html, /in the next block/); + assert.doesNotMatch(html, /\[object Object\]/); +}); + +test("keeps fee market panels visible while estimates are unavailable", () => { + const html = render(feeMarket({ t })); + + assert.match(html, /Fee Market/); + assert.match(html, /Low/); + assert.match(html, /Average/); + assert.match(html, /High/); + assert.match(html, /N\/A/); + assert.doesNotMatch(html, /sat\/vB/); + assert.doesNotMatch(html, /USD/); +}); + +test("passes fee market copy through localization", () => { + const localizedStrings = new Set(); + const localizedT = (parts, ...values) => { + localizedStrings.add(parts.join("%s")); + return parts.reduce( + (result, part, index) => + result + part + (index < values.length ? values[index] : ""), + "", + ); + }; + + render(feeMarket({ t: localizedT })); + + [ + "A balanced fee rate estimated to confirm within %s blocks.", + "A higher-priority fee rate estimated to confirm in the next block.", + "A lower-priority fee rate estimated to confirm within %s blocks.", + "Average", + "Fee Market", + "High", + "Low", + "N/A", + ].forEach((value) => assert(localizedStrings.has(value), value)); +}); diff --git a/test/fees.test.js b/test/fees.test.js index 9a74331d..c82ab803 100644 --- a/test/fees.test.js +++ b/test/fees.test.js @@ -3,6 +3,7 @@ const assert = require("node:assert/strict"); const { feeRateClass, + estimateTypicalTransactionFeeUsd, feerateCutoff, getConfEstimate, getFeeTierBoundaries, @@ -10,12 +11,12 @@ const { test("validates and applies fee tier boundaries", () => { assert.deepEqual(getFeeTierBoundaries({ 3: 10, 12: 2 }), { - low: 2, - high: 10, + lowMaximum: 2, + mediumMaximum: 10, }); assert.deepEqual(getFeeTierBoundaries({ 3: 1, 12: 2 }), { - low: 2, - high: 2, + lowMaximum: 2, + mediumMaximum: 2, }); assert.equal(getFeeTierBoundaries(), null); assert.equal(getFeeTierBoundaries({ 3: 10, 12: -1 }), null); @@ -55,3 +56,14 @@ test("selects a confirmation target by numeric order", () => { assert.equal(getConfEstimate(estimates, 6), "10"); assert.equal(getConfEstimate(estimates, 0.5), -1); }); + +test("estimates a typical transaction fee in dollars", () => { + const expectedFee = process.env.IS_ELEMENTS ? 1.548 : 0.84; + + assert.equal(estimateTypicalTransactionFeeUsd(50_000, 12), expectedFee); + assert.equal(estimateTypicalTransactionFeeUsd(50_000, 0), 0); + assert.equal(estimateTypicalTransactionFeeUsd(null, 12), null); + assert.equal(estimateTypicalTransactionFeeUsd(50_000, undefined), null); + assert.equal(estimateTypicalTransactionFeeUsd(-50_000, 12), null); + assert.equal(estimateTypicalTransactionFeeUsd(50_000, -12), null); +}); diff --git a/test/format-number.test.js b/test/format-number.test.js index 4dda28f5..78804b23 100644 --- a/test/format-number.test.js +++ b/test/format-number.test.js @@ -1,7 +1,11 @@ const test = require("node:test"); const assert = require("node:assert/strict"); -const { formatNumber } = require("../client/src/views/util"); +const { + formatFeeRate, + formatNumber, + formatUsd, +} = require("../client/src/views/util"); test("separates whole-number digits into groups of three", () => { assert.equal(formatNumber(999), "999"); @@ -17,3 +21,17 @@ test("preserves exact decimal strings and requested precision", () => { ); assert.equal(formatNumber("1234.5", 3), "1,234.500"); }); + +test("formats fee rates with up to two decimal places and a fallback", () => { + assert.equal(formatFeeRate(2), "2 sat/vB"); + assert.equal(formatFeeRate(2.5), "2.5 sat/vB"); + assert.equal(formatFeeRate(2.555), "2.56 sat/vB"); + assert.equal(formatFeeRate(null), "N/A"); + assert.equal(formatFeeRate(undefined, "-"), "-"); +}); + +test("formats dollar values with a fallback", () => { + assert.equal(formatUsd(1_234.5), "$1,234.50 USD"); + assert.equal(formatUsd(null), "N/A"); + assert.equal(formatUsd(undefined, "-"), "-"); +}); diff --git a/test/market.test.js b/test/market.test.js new file mode 100644 index 00000000..e11ec1c4 --- /dev/null +++ b/test/market.test.js @@ -0,0 +1,20 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); + +const { + getBitcoinPrices, + getLatestBitcoinPrice, +} = require("../client/src/lib/market"); + +test("selects finite market prices and the latest available value", () => { + const marketChart = { + prices: [[1, 100], null, [2, NaN], [3, 125]], + }; + + assert.deepEqual(getBitcoinPrices(marketChart), [100, 125]); + assert.equal(getLatestBitcoinPrice(marketChart), 125); + assert.deepEqual(getBitcoinPrices(null), []); + assert.equal(getLatestBitcoinPrice(null), null); + assert.deepEqual(getBitcoinPrices({ prices: {} }), []); + assert.deepEqual(getBitcoinPrices({ prices: "invalid" }), []); +}); diff --git a/test/overview.test.js b/test/overview.test.js new file mode 100644 index 00000000..8a39b279 --- /dev/null +++ b/test/overview.test.js @@ -0,0 +1,36 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const render = require("snabbdom-to-html"); + +const { overview } = require("../client/src/views/overview"); + +const t = (parts, ...values) => parts.reduce( + (result, part, index) => + result + part + (index < values.length ? values[index] : ""), + "", +); + +test("formats the recommended fee rate and dollar estimate", () => { + const html = render(overview({ + blocks: [], + feeEst: { 3: 6 }, + mempool: { vsize: 0 }, + bitcoinMarketChart: { prices: [[1, 50_000]] }, + t, + })); + + const expectedFeeUsd = process.env.IS_ELEMENTS ? "$0.77 USD" : "$0.42 USD"; + + assert.match( + html, + /Recommended Fee[\s\S]*6 sat\/vB/, + ); + assert(html.includes(expectedFeeUsd), expectedFeeUsd); + assert.match(html, /\$50,000\.00 USD/); +}); + +test("shows unavailable overview fee and price values explicitly", () => { + const html = render(overview({ blocks: [], mempool: null, t })); + + assert.match(html, /Recommended Fee[\s\S]*N\/A[\s\S]*N\/A/); +}); diff --git a/test/pending-block-details.test.js b/test/pending-block-details.test.js index 2d9bdfcf..d6596145 100644 --- a/test/pending-block-details.test.js +++ b/test/pending-block-details.test.js @@ -5,14 +5,11 @@ const { formatCount, formatEstimatedBlockTime, formatFeeBoundary, - formatFeeRate, formatMegabytes, formatPanelPercentage, formatPercentage, formatTransactionDelta, - formatUsd, formatWeight, - getLatestBitcoinPrice, } = require("../client/src/lib/pending-block-details"); const { targetBlockIntervalSeconds, @@ -62,22 +59,13 @@ test("counts down to and measures time past the expected block interval", () => test("formats pending-block metric values and fallbacks", () => { assert.equal(formatPercentage(12.345), "12.35%"); assert.equal(formatPanelPercentage(12.3), "12.3%"); - assert.equal(formatFeeRate(2.5), "2.50 sat/vB"); assert.equal(formatFeeBoundary(2.5), "2.50"); assert.equal(formatWeight(1_250_000), "1.25 MWU"); assert.equal(formatMegabytes(2_500_000), "2.5 MB"); assert.equal(formatCount(12_345), "12,345"); - assert.equal(formatUsd(1_234.5), "$1,234.50 USD"); assert.equal(formatPercentage(null, "-"), "-"); }); -test("selects the latest finite market price", () => { - assert.equal(getLatestBitcoinPrice({ - prices: [[1, 100], null, [2, NaN], [3, 125]], - }), 125); - assert.equal(getLatestBitcoinPrice(null), null); -}); - test("formats pending transaction changes", () => { assert.equal(formatTransactionDelta(12), "+ 12"); assert.equal(formatTransactionDelta(-12), "− 12"); diff --git a/www/style.css b/www/style.css index 0f75ff53..9da6f397 100644 --- a/www/style.css +++ b/www/style.css @@ -3946,6 +3946,31 @@ a.back-link img{ gap: 12px; } +.fee-market { + display: flex; + flex-direction: column; + gap: 12px; +} + +.fee-market-body { + display: flex; + gap: var(--page-gap); +} + +.fee-market .info-card-container { + flex: 1; + width: auto; + min-width: 0; +} + +.fee-market-low .info-card-value { + color: var(--success-color); +} + +.fee-market-high .info-card-value { + color: var(--danger-color); +} + .info-stats-row { display: flex; gap: 12px; @@ -5665,6 +5690,14 @@ a.back-link img{ .dashboard-transaction-section { flex-direction: column; } + + .fee-market-body { + flex-wrap: wrap; + } + + .fee-market .info-card-container { + flex-basis: 260px; + } } @media only screen and (max-width: 1328px) and (prefers-reduced-motion: reduce) {