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 ( +
+
+
+ +
+

{t`High-Value Assets`}

+ +
+ +
+ {rows.map(({ asset, dollarAmount }) => { + return ( + +
+ +
+

{asset.name}

+

+ {formatDollarAmount(dollarAmount)} +

+
+ ); + })} +
+ + {t`See more`} +
+ +
+
+
+ ); +}; 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; }