diff --git a/README.md b/README.md
index ab387276..a9e6b6cf 100644
--- a/README.md
+++ b/README.md
@@ -110,6 +110,7 @@ Elements-only configuration:
- `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)
+- `SHOW_HIGH_VALUE_ASSETS` - set to `1` to show circulating values for selected assets on the Liquid mainnet dashboard (enabled by the Liquid mainnet flavor)
- `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 03f81385..e8249f81 100644
--- a/client/src/app.js
+++ b/client/src/app.js
@@ -4,9 +4,19 @@ import {setAdapt} from '@cycle/run/lib/adapt';
import { getMempoolDepth, getConfEstimate, calcSegwitFeeGains } from './lib/fees'
import { summarizeBlockTemplate } from './lib/block-template'
+import { getPriceFeedApiBase } from './lib/high-value-assets'
import { isBitcoinNetwork } from './lib/network'
import getPrivacyAnalysis from './lib/privacy-analysis'
-import { nativeAssetId, blockTxsPerPage, blocksPerPage, difficultyPeriod, showPegData, blockGridTransactionSelectEvent } from './const'
+import {
+ highValueAssetDefinitions,
+ nativeAssetId,
+ blockTxsPerPage,
+ blocksPerPage,
+ difficultyPeriod,
+ showHighValueAssets,
+ showPegData,
+ blockGridTransactionSelectEvent
+} from './const'
import {
dbg,
combine,
@@ -28,14 +38,37 @@ import {
import l10n, { defaultLang } from './l10n'
import * as views from './views'
-const apiBase = (process.env.API_URL || '/api').replace(/\/+$/, '')
+const highValueAssetCategory = assetId => `dashboard-high-value-asset-${assetId}`
+ , highValueAssetPriceCategory = assetId => `dashboard-high-value-asset-price-${assetId}`
+ , apiBase = (process.env.API_URL || '/api').replace(/\/+$/, '')
, bitcoinMarketChartUrl = process.env.BITCOIN_MARKET_CHART_URL || 'https://api.coingecko.com/api/v3/coins/bitcoin/market_chart?vs_currency=usd&days=1&interval=hourly'
, blockTemplatePollIntervalMs = 30000
// Wait one electrs cache window after a new tip before requesting the next
// template. If a refresh is still in progress, electrs holds the request
// until fresh data is ready, so no additional client-side jitter is needed.
, blockTemplatePollAfterNewBlockMs = 15000
+ , priceFeedApiBase = getPriceFeedApiBase(
+ apiBase,
+ process.browser ? window.location.origin : process.env.CANONICAL_URL
+ )
, setBase = ({ path, ...r }) => ({ ...r, url: path.includes('://') || path.startsWith('./') ? path : apiBase + path })
+ , highValueAssetRequests = !showHighValueAssets ? [] : highValueAssetDefinitions.reduce((requests, asset) => [
+ ...requests,
+ {
+ category: highValueAssetCategory(asset.asset_id),
+ method: 'GET',
+ path: `/asset/${asset.asset_id}`,
+ assetId: asset.asset_id,
+ bg: true
+ },
+ ...(priceFeedApiBase ? [{
+ category: highValueAssetPriceCategory(asset.asset_id),
+ method: 'GET',
+ path: `${priceFeedApiBase}/api/v1/assets/${asset.asset_id}/price`,
+ assetId: asset.asset_id,
+ bg: true
+ }] : [])
+ ], [])
const reservedPaths = [ 'mempool', 'assets', 'search' ]
, NEW_TABLE_ENTRY_MS = 2000
@@ -375,8 +408,40 @@ export default function main({ DOM, HTTP, route, storage, scanner: scan$, search
: 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 }))
+ , dashboardHighValueAssets$ = !showHighValueAssets
+ ? O.of({})
+ : O.merge(...highValueAssetDefinitions.reduce((replies, asset) => [
+ ...replies,
+ reply(highValueAssetCategory(asset.asset_id), true)
+ .map(r => state => ({
+ ...state,
+ [r.request.assetId]: {
+ ...state[r.request.assetId],
+ asset: r.body
+ }
+ })),
+ reply(highValueAssetPriceCategory(asset.asset_id), true)
+ .map(r => state => ({
+ ...state,
+ [r.request.assetId]: {
+ ...state[r.request.assetId],
+ price: r.body && r.body.data
+ }
+ }))
+ ], []))
+ .scan((state, update) => update(state), {})
+ .startWith({})
+ , dashboardState$ = O.combineLatest(
+ blocks$,
+ mempoolRecent$,
+ dashboardPegState$,
+ dashboardHighValueAssets$,
+ (blks, txs, peg, highValueAssets) => ({
+ dashblocks: blks.slice(0, 5),
+ dashTxs: txs.slice(0, 11),
+ peg,
+ highValueAssets
+ }))
, dashboardEpochStartBlock$ = reply('dashboard-epoch-start-block', true)
.map(r => ({ ...r.body, requestedHeight: r.request.height }))
@@ -632,6 +697,12 @@ export default function main({ DOM, HTTP, route, storage, scanner: scan$, search
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 }])
+
+ // fetch asset stats and USD prices only while viewing the Liquid dashboard
+ , !showHighValueAssets ? O.empty() :
+ O.merge(goHome$, tickWhileViewing(60000, 'dashBoard', view$))
+ .throttleTime(1000)
+ .flatMap(_ => highValueAssetRequests)
//
// elements/liquid only
//
diff --git a/client/src/components/high-value-assets.js b/client/src/components/high-value-assets.js
new file mode 100644
index 00000000..cbcde1e5
--- /dev/null
+++ b/client/src/components/high-value-assets.js
@@ -0,0 +1,72 @@
+import { CurrencyDollarIcon } from "./icons";
+import { Tooltip } from "./tooltip";
+import { highValueAssetDefinitions as assets, staticRoot } from "../const";
+import {
+ calculateCirculatingDollarAmount,
+ formatDollarAmount,
+} from "../lib/high-value-assets";
+
+export const highValueAssets = (t, assetData = {}) => {
+ const rows = assets
+ .map((asset, index) => {
+ const data = assetData[asset.asset_id] || {};
+
+ return {
+ asset,
+ dollarAmount: calculateCirculatingDollarAmount(data.asset, data.price),
+ index,
+ };
+ })
+ .sort((left, right) => {
+ if (left.dollarAmount == null) {
+ return right.dollarAmount == null ? left.index - right.index : 1;
+ }
+ if (right.dollarAmount == null) return -1;
+
+ return right.dollarAmount - left.dollarAmount || left.index - right.index;
+ });
+
+ return (
+
+ );
+};
diff --git a/client/src/const.js b/client/src/const.js
index 8f75dfe5..6600650d 100644
--- a/client/src/const.js
+++ b/client/src/const.js
@@ -18,17 +18,45 @@ export const feeEstimateTargets = {
average: 3,
high: 1,
}
+export const staticRoot = process.env.STATIC_ROOT || ''
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'
+const liquidNativeAssetId = '6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d'
+
+export const nativeAssetId = process.env.NATIVE_ASSET_ID || liquidNativeAssetId
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'
+export const highValueAssetDefinitions = [
+ {
+ asset_id: 'aa909f1b77451e409fe95fe1d3638ad017ab3325c6d4f00301af6d582d0f2034',
+ name: 'BMN2',
+ icon: 'hva-bmn2.svg'
+ },
+ {
+ asset_id: 'ce091c998b83c78bb71a632313ba3760f1763d9cfcffae02258ffa9865a37bd2',
+ name: 'USDT',
+ icon: 'hva-usdt.svg'
+ },
+ {
+ asset_id: 'e8305bb5c1794b256a858a01e5d8af7a5817d257fbfbc2c9d49620f13ff401a9',
+ name: 'CMSTR'
+ },
+ {
+ asset_id: '26ac924263ba547b706251635550a8649545ee5c074fe5db8d7140557baaf32e',
+ name: 'MEXAS'
+ }
+]
+
+export const showHighValueAssets = !!process.env.IS_ELEMENTS
+ && process.env.SHOW_HIGH_VALUE_ASSETS == '1'
+ && nativeAssetId == liquidNativeAssetId
+
// Elements only
export const assetTxsPerPage = 25
export const pegTxsPerPage = 25
diff --git a/client/src/lib/high-value-assets.js b/client/src/lib/high-value-assets.js
new file mode 100644
index 00000000..a6328392
--- /dev/null
+++ b/client/src/lib/high-value-assets.js
@@ -0,0 +1,51 @@
+const value = n => n == null ? 0 : Number(n)
+
+export const getPriceFeedApiBase = (apiBase, fallbackOrigin) => {
+ let url
+
+ try {
+ url = new URL(apiBase, fallbackOrigin)
+ } catch (_) {
+ return null
+ }
+
+ return `${url.origin}/price`
+}
+
+export const calculateCirculatingDollarAmount = (asset, price) => {
+ if (!asset || !price || price.price_usd == null || !Number.isFinite(Number(price.price_usd))) return null
+
+ const { chain_stats = {}, mempool_stats = {} } = asset
+ , precision = asset.precision == null ? 0 : Number(asset.precision)
+ , issuedAmount = value(chain_stats.issued_amount) + value(mempool_stats.issued_amount)
+ , burnedAmount = value(chain_stats.burned_amount) + value(mempool_stats.burned_amount)
+ , hasBlindedIssuances = chain_stats.has_blinded_issuances || mempool_stats.has_blinded_issuances
+ , circulatingAmount = issuedAmount - burnedAmount
+
+ if (
+ hasBlindedIssuances ||
+ !Number.isFinite(circulatingAmount) ||
+ circulatingAmount < 0 ||
+ !Number.isInteger(precision) ||
+ precision < 0
+ ) return null
+
+ const dollarAmount = circulatingAmount / Math.pow(10, precision) * Number(price.price_usd)
+ return Number.isFinite(dollarAmount) ? dollarAmount : null
+}
+
+export const formatDollarAmount = amount => {
+ if (!Number.isFinite(amount)) return 'N/A'
+
+ const units = [
+ [ 1e12, 'T' ],
+ [ 1e9, 'B' ],
+ [ 1e6, 'M' ],
+ [ 1e3, 'K' ]
+ ]
+ const unit = units.find(([ divisor ]) => Math.abs(amount) >= divisor)
+
+ return unit
+ ? `$${(amount / unit[0]).toFixed(1)}${unit[1]}`
+ : `$${amount.toFixed(1)}`
+}
diff --git a/client/src/views/home.js b/client/src/views/home.js
index df7d1f61..826911ce 100644
--- a/client/src/views/home.js
+++ b/client/src/views/home.js
@@ -6,7 +6,8 @@ import { overview } from "./overview";
import { feeMarket } from "./fee-market";
import difficultyAdjustment from "./difficulty-adjustment";
import { isBitcoinNetwork } from "../lib/network";
-import { showPegData } from "../const";
+import { showHighValueAssets, showPegData } from "../const";
+import { highValueAssets } from "../components/high-value-assets";
const isTouch = process.browser && "ontouchstart" in window;
@@ -14,7 +15,7 @@ const homeLayout = (body, { t, activeTab, ...S }) =>
layout(body, { t, isTouch, activeTab, ...S });
export const dashBoard = ({ t, blocks, dashboardState, loading, ...S }) => {
- const { dashblocks, dashTxs, peg = {} } = dashboardState || {};
+ const { dashblocks, dashTxs, peg = {}, highValueAssets: highValueAssetData } = dashboardState || {};
return homeLayout(
@@ -31,6 +32,7 @@ export const dashBoard = ({ t, blocks, dashboardState, loading, ...S }) => {
{isBitcoinNetwork
? difficultyAdjustment({ blocks: dashblocks, ...S })
: ""}
+ {showHighValueAssets ? highValueAssets(t, highValueAssetData) : ""}
,
{ ...S, t, activeTab: "dashBoard" },
);
diff --git a/flavors/liquid-mainnet/config.env b/flavors/liquid-mainnet/config.env
index 58441c4d..d5827f84 100755
--- a/flavors/liquid-mainnet/config.env
+++ b/flavors/liquid-mainnet/config.env
@@ -7,6 +7,7 @@ export NATIVE_ASSET_LABEL=LBTC
export NATIVE_ASSET_NAME='Liquid Bitcoin'
export IS_ELEMENTS=1
export SHOW_PEG_DATA=${SHOW_PEG_DATA:-1}
+export SHOW_HIGH_VALUE_ASSETS=${SHOW_HIGH_VALUE_ASSETS:-1}
export ASSET_MAP_URL=./_data/assets.minimal.json
diff --git a/test/high-value-assets.test.js b/test/high-value-assets.test.js
new file mode 100644
index 00000000..3a774ce4
--- /dev/null
+++ b/test/high-value-assets.test.js
@@ -0,0 +1,114 @@
+const test = require('node:test')
+const assert = require('node:assert/strict')
+const render = require('snabbdom-to-html')
+
+const {
+ calculateCirculatingDollarAmount,
+ formatDollarAmount,
+ getPriceFeedApiBase
+} = require('../client/src/lib/high-value-assets')
+const { highValueAssetDefinitions } = require('../client/src/const')
+const { highValueAssets } = require('../client/src/components/high-value-assets')
+
+test('builds a price feed base from absolute and relative API URLs', () => {
+ assert.equal(
+ getPriceFeedApiBase('https://blockstream.info/liquid/api'),
+ 'https://blockstream.info/price'
+ )
+ assert.equal(
+ getPriceFeedApiBase('/liquid/api', 'https://blockstream.info'),
+ 'https://blockstream.info/price'
+ )
+})
+
+test('calculates circulating USD value from confirmed and mempool issuance and burns', () => {
+ const asset = {
+ precision: 8,
+ chain_stats: { issued_amount: 150000000, burned_amount: 10000000 },
+ mempool_stats: { issued_amount: 50000000, burned_amount: 20000000 }
+ }
+
+ assert.equal(calculateCirculatingDollarAmount(asset, { price_usd: 3 }), 5.1)
+ assert.equal(formatDollarAmount(2180000000), '$2.2B')
+})
+
+test('handles zero burns, zero circulating supply, and unavailable prices', () => {
+ const asset = {
+ precision: 2,
+ chain_stats: { issued_amount: 500, burned_amount: 0 },
+ mempool_stats: { issued_amount: 0, burned_amount: 0 }
+ }
+
+ assert.equal(calculateCirculatingDollarAmount(asset, { price_usd: 5 }), 25)
+ assert.equal(calculateCirculatingDollarAmount({
+ ...asset,
+ chain_stats: { issued_amount: 500, burned_amount: 500 }
+ }, { price_usd: 5 }), 0)
+ assert.equal(calculateCirculatingDollarAmount(asset, null), null)
+ assert.equal(calculateCirculatingDollarAmount(asset, { price_usd: null }), null)
+ assert.equal(formatDollarAmount(null), 'N/A')
+})
+
+test('does not calculate circulating value for blinded issuances', () => {
+ const asset = {
+ precision: 2,
+ chain_stats: {
+ issued_amount: 500,
+ burned_amount: 100,
+ has_blinded_issuances: true
+ },
+ mempool_stats: { issued_amount: 100, burned_amount: 50 }
+ }
+
+ assert.equal(calculateCirculatingDollarAmount(asset, { price_usd: 5 }), null)
+})
+
+test('renders custom and fallback icons with circulating USD amounts', () => {
+ const bmn2 = highValueAssetDefinitions.find(asset => asset.name == 'BMN2')
+ , html = render(highValueAssets(strings => strings[0], {
+ [bmn2.asset_id]: {
+ asset: {
+ precision: 2,
+ chain_stats: { issued_amount: 100000000000, burned_amount: 20000000000 },
+ mempool_stats: { issued_amount: 900000000, burned_amount: 900000000 }
+ },
+ price: { price_usd: 2 }
+ }
+ }))
+
+ assert.match(html, /img\/icons\/hva-bmn2\.svg/)
+ assert.match(html, /img\/icons\/hva-usdt\.svg/)
+ assert.match(html, /img\/icons\/hva-default\.svg/)
+ assert.match(html, /img\/icons\/tooltip\.svg/)
+ assert.match(html, /circulating value of high-value assets on Liquid/)
+ assert.match(html, new RegExp(`href="asset/${bmn2.asset_id}"`))
+ assert.match(html, /\$1\.6B/)
+})
+
+test('sorts assets by circulating USD amount with unavailable amounts last', () => {
+ const circulatingAmounts = {
+ BMN2: 100,
+ CMSTR: 300,
+ MEXAS: 200
+ }
+ , assetData = highValueAssetDefinitions.reduce((data, asset) => ({
+ ...data,
+ ...(circulatingAmounts[asset.name] == null ? {} : {
+ [asset.asset_id]: {
+ asset: {
+ precision: 0,
+ chain_stats: { issued_amount: circulatingAmounts[asset.name] }
+ },
+ price: { price_usd: 1 }
+ }
+ })
+ }), {})
+ , html = render(highValueAssets(strings => strings[0], assetData))
+ , positions = [ 'CMSTR', 'MEXAS', 'BMN2', 'USDT' ].map(name => {
+ const asset = highValueAssetDefinitions.find(asset => asset.name == name)
+ return html.indexOf(`href="asset/${asset.asset_id}"`)
+ })
+
+ assert.ok(positions.every(position => position >= 0))
+ assert.deepEqual(positions, [ ...positions ].sort((a, b) => a - b))
+})
diff --git a/www/img/icons/hva-bmn2.svg b/www/img/icons/hva-bmn2.svg
new file mode 100644
index 00000000..c2c14516
--- /dev/null
+++ b/www/img/icons/hva-bmn2.svg
@@ -0,0 +1,10 @@
+
diff --git a/www/img/icons/hva-default.svg b/www/img/icons/hva-default.svg
new file mode 100644
index 00000000..9689bf61
--- /dev/null
+++ b/www/img/icons/hva-default.svg
@@ -0,0 +1,5 @@
+
diff --git a/www/img/icons/hva-usdt.svg b/www/img/icons/hva-usdt.svg
new file mode 100644
index 00000000..ceaeb8c9
--- /dev/null
+++ b/www/img/icons/hva-usdt.svg
@@ -0,0 +1,4 @@
+
diff --git a/www/style.css b/www/style.css
index beee795e..8c1cb533 100644
--- a/www/style.css
+++ b/www/style.css
@@ -4815,6 +4815,76 @@ a.back-link img{
border-radius: var(--max-border-radius);
}
+.high-value-assets-table {
+ padding: 24px;
+ border-radius: 12px;
+ background-color: var(--surface-primary-color);
+}
+
+.high-value-assets-body {
+ display: flex;
+ justify-content: flex-start;
+ justify-content: safe center;
+ gap: 32px;
+ margin-top: 12px;
+}
+
+.high-value-assets-listing {
+ display: flex;
+ box-sizing: border-box;
+ width: 130px;
+ flex-direction: column;
+ align-items: center;
+ border-radius: 4px;
+ padding: 8px 12px;
+}
+
+.high-value-assets-listing,
+.high-value-assets-listing:link,
+.high-value-assets-listing:visited,
+.high-value-assets-listing:hover,
+.high-value-assets-listing:focus {
+ color: inherit;
+ text-decoration: none;
+}
+
+.high-value-assets-listing:hover,
+.high-value-assets-listing:focus {
+ background-color: rgba(34, 225, 201, 0.1);
+ cursor: pointer;
+}
+
+.high-value-assets-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 64px;
+ height: 64px;
+ background-color: #22E1C9;
+ border-radius: 20px;
+}
+
+.high-value-assets-icon img {
+ display: block;
+ max-width: 50px;
+ max-height: 50px;
+}
+
+.high-value-assets-listing-name {
+ font-size: 10px;
+ font-weight: 400;
+ text-align: center;
+ margin-top: 6px;
+}
+
+.high-value-assets-circulating-dollar-amount {
+ line-height: 1;
+ margin-top: 12px;
+ font-size: 28px;
+ font-weight: 700;
+ font-family: 'Rigid Square';
+}
+
@media only screen and (max-width: 1328px) {
.asset-table {
display: grid;
@@ -5431,6 +5501,11 @@ a.back-link img{
);
}
+ .high-value-assets-body {
+ overflow-x: auto;
+ overflow-y: hidden;
+ }
+
.explorer-container {
box-sizing: border-box;
}