diff --git a/client/src/components/icons.js b/client/src/components/icons.js index 7eea2bb7..8220c997 100644 --- a/client/src/components/icons.js +++ b/client/src/components/icons.js @@ -26,6 +26,11 @@ export const PrivacyAnalysisArrowIcon = ({ className } = {}) => +export const LinkIcon = ({ className } = {}) => + + export const CopyIcon = ({ className } = {}) => +export const DefaultAssetIcon = ({ className } = {}) => + + +export const AssetContractIcon = ({ className } = {}) => + + +export const BurnIcon = ({ className } = {}) => + + export const CurrencyDollarIcon = ({ className } = {}) =>
-
- - {t`All Assets`} - -

{t`Asset`}

-
- {asset.asset_id} - { process.browser &&
-
-
} +
+
+
+
-
-
, -
-
-
-
{t`Asset id`}
-
{asset.asset_id}
-
- - { is_native_asset - // Native asset - ? [ -
-
{t`Name`}
-
{nativeAssetName}
-
- - ,
-
{t`Ticker`}
-
{nativeAssetLabel}
-
- - ,
-
{t`Pegged in`}
-
{formatSat(chain_stats.peg_in_amount)}
-
- - , mempool_stats.peg_in_amount > 0 &&
-
{t`Pegged in (unconfirmed)`}
-
{formatSat(mempool_stats.peg_in_amount)}
-
- - ,
-
{t`Pegged out`}
-
{formatSat(chain_stats.peg_out_amount)}
-
- - , mempool_stats.peg_out_amount > 0 &&
-
{t`Pegged out (unconfirmed)`}
-
{formatSat(mempool_stats.peg_out_amount)}
-
- - ,
-
{t`Burned`}
-
{formatSat(chain_stats.burned_amount)}
-
- - , mempool_stats.burned_amount > 0 &&
-
{t`Burned (unconfirmed)`}
-
{formatSat(mempool_stats.burned_amount)}
-
- - ,
-
{t`Circulating amount`}
-
{formatSat(circulating)}
-
- - ,
-
{t`Peg/burn transaction count`}
-
{formatNumber(chain_stats.tx_count)}
-
- - , mempool_stats.peg_out_amount > 0 &&
-
{t`Peg/burn transaction count (unconfirmed)`}
-
{formatNumber(mempool_stats.tx_count)}
-
- ] - - // Issued assets - : [ - asset.name &&
-
{t`Name`}
-
{asset.name}
-
- - ,
-
{t`Precision - decimal places`}
-
{asset.precision || 0}
-
- - , asset.ticker &&
-
{t`Ticker`}
-
{asset.ticker}
-
- - , asset.entity &&
-
{t(`Issuer ${entity_type}`)}
-
{asset.entity[entity_type]}
-
- - , - - ,
-
{t`Included in Block`}
-
{ asset.status.confirmed - ? {asset.status.block_hash} - : t`Unconfirmed` - }
-
- - ,
-
{t`Number of issuances`}
-
{chain_stats.issuance_count}
-
- - , mempool_stats.issuance_count > 0 &&
-
{t`Number of issuances (unconfirmed)`}
-
{mempool_stats.issuance_count}
-
- - ,
-
{t`Issued amount`}
-
{chain_stats.has_blinded_issuances ? - : formatAssetAmount(chain_stats.issued_amount, asset.precision, t) }
-
- - , mempool_stats.issued_amount > 0 &&
-
{t`Issued amount (unconfirmed)`}
-
{formatAssetAmount(mempool_stats.issued_amount, asset.precision, t)}
-
- - , chain_stats.burned_amount > 0 &&
-
{t`Burned amount`}
-
{formatAssetAmount(chain_stats.burned_amount, asset.precision, t)}
-
- - , mempool_stats.burned_amount > 0 &&
-
{t`Burned amount (unconfirmed)`}
-
{formatAssetAmount(mempool_stats.burned_amount, asset.precision, t)}
-
- - ,
-
{t`Reissuance tokens created`}
-
{chain_stats.reissuance_tokens == null ? - : chain_stats.reissuance_tokens === 0 ? t`None` - : formatNumber(chain_stats.reissuance_tokens) }
-
- - , chain_stats.burned_reissuance_tokens > 0 &&
-
{t`Reissuance tokens burned`}
-
{formatNumber(chain_stats.burned_reissuance_tokens)}
-
- - , mempool_stats.burned_reissuance_tokens > 0 &&
-
{t`Reissuance tokens burned (unconfirmed)`}
-
{formatNumber(mempool_stats.burned_reissuance_tokens)}
-
- - ,
-
{t`Circulating amount`}
-
{ circulating == null ? - : formatAssetAmount(circulating, asset.precision, t) }
-
+
+
+

{asset_title}

+ {!is_native_asset && !has_contract ? ( + + + {t`Unregistered`} + + ) : null} +
- ,
-
{t`Re-issuable`}
-
{ is_non_reissuable ? t`No` : t`Yes` }
-
+
+
+ {stats.map(stat => ( + + ))} +
+ {asset.issuance_txin ? ( + +

{t`View Issuance Tx`}

+ +
+ ) : null} +
+
+
- , asset.contract_hash &&
-
{t`Contract hash`}
-
{asset.contract_hash}
-
+ {!is_native_asset ? ( +
+
+
+ +
+

{t`Contract`}

+
- , asset.contract &&
-
{t`Contract JSON`}
-
{formatJson(asset.contract)}
+
+
+ {detailField({ + label: t`Asset ID`, + value: asset.asset_id, + className: 'mono', + copyValue: asset.asset_id + })} +
+
+ {detailField({ + label: t`Contract hash`, + value: display(asset.contract_hash), + className: 'mono' + })} +
+ {has_contract ? [ +
+ {detailField({ + label: t`Linked domain`, + value: display(contract_entity.domain) + })} +
, +
+ {detailField({ + label: 'ISSUER PUBKEY', + value: display(contract.issuer_pubkey), + className: 'mono' + })} +
, +
+ {detailField({ label: t`Asset name`, value: display(contract.name) })} +
, +
+ {detailField({ + label: t`Decimal places`, + value: display(contract.precision) + })} +
, +
+ {detailField({ label: t`Ticker`, value: display(contract.ticker) })} +
, +
+ {detailField({ label: t`Version`, value: display(contract.version) })}
- ] - } - -
+ ] : null} +
-
-
-

{(is_native_asset ? txsShownTextNative : txsShownTextIssued)(total_txs, est_prev_total_seen_count, shown_txs, t)}

- { assetTxs ? assetTxs.map(tx => txBox(tx, { openTx, tipHeight, t, spends, ...S })) - : loader()} + {has_contract ? ( + +

Raw JSON

+ +
+ ) : null}
+ ) : null} + +
+ {assetTxs + ? assetTxs.map(tx => { + const { title, Icon } = assetTransactionHeader(tx, asset, t) + + return txBox(tx, { + openTx, + tipHeight, + t, + spends, + headerTitle: title, + HeaderIcon: Icon, + ...S + }) + }) + : loader()} +
-
-
- { loading ?
{t`Loading...`}
{loader("small")}
- : pagingNav(asset, last_seen_txid, est_curr_chain_seen_count, prev_paging_txids, next_paging_txids, prev_paging_est_count, t) } -
+
+
+ {loading ? ( +
+ {t`Loading...`} +
{loader('small')}
+
+ ) : ( + pagingNav( + asset, + last_seen_txid, + est_curr_chain_seen_count, + prev_paging_txids, + next_paging_txids, + prev_paging_est_count, + t + ) + )}
-
- ] - , { t, activeTab: 'assets', ...S }) + ], + { t, activeTab: 'assets', ...S } + ) } -const txsShownTextIssued = (total, start, shown, t) => - (total > perPage && shown > 0) - ? t`${ start > 0 ? `${start}-${+start+shown}` : shown} of ${formatNumber(total)} Issuance and Burn Transactions` - : t`${total} Issuance and Burn Transactions` - -const txsShownTextNative = (total, start, shown, t) => - (total > perPage && shown > 0) - ? t`${ start > 0 ? `${start}-${+start+shown}` : shown} of ${formatNumber(total)} Peg In/Out and Burn Transactions` - : t`${total} Peg In/Out and Burn transactions` +const value = n => n || 0 + +const display = value => + value == null || value === '' ? 'N/A' : value + +const detailField = ({ label, value, className, copyValue }) => +
+

{label}

+
+

{value}

+ {copyValue ? ( + + ) : null} +
+
+ +const isBurnOutput = (vout, asset) => { + const is_target_asset = + vout.asset === asset.asset_id || vout.asset === asset.reissuance_token + const scriptpubkey = vout.scriptpubkey || '' + const scriptpubkey_asm = vout.scriptpubkey_asm || '' + + return is_target_asset && ( + vout.scriptpubkey_type === 'op_return' || + vout.scriptpubkey_type === 'provably_unspendable' || + scriptpubkey_asm.indexOf('OP_RETURN') === 0 || + scriptpubkey.toLowerCase().indexOf('6a') === 0 + ) +} +export const assetTransactionHeader = (tx, asset, t) => { + const headers = [] + , vins = tx.vin || [] + , vouts = tx.vout || [] + , issuances = vins + .map(vin => vin.issuance) + .filter(issuance => issuance && issuance.asset_id === asset.asset_id) + + if (!asset.issuance_txin) { + if (vins.some(vin => vin.is_pegin)) { + headers.push({ title: t`Peg In Transaction`, Icon: TxArrowsIcon }) + } + if (vouts.some(vout => vout.pegout)) { + headers.push({ title: t`Peg Out Transaction`, Icon: TxArrowsIcon }) + } + } else { + if (issuances.some(issuance => !issuance.is_reissuance)) { + headers.push({ title: t`Issuance Transaction`, Icon: TxArrowsIcon }) + } + if (issuances.some(issuance => issuance.is_reissuance)) { + headers.push({ title: t`Reissuance Transaction`, Icon: TxArrowsIcon }) + } + } + + if (vouts.some(vout => isBurnOutput(vout, asset))) { + headers.push({ title: t`Burn Transaction`, Icon: BurnIcon }) + } + + if (!headers.length) { + headers.push({ + title: asset.issuance_txin ? t`Asset Transaction` : t`Peg Transaction`, + Icon: TxArrowsIcon + }) + } + + return { + title: headers.map(header => header.title).join(' + '), + Icon: headers.length === 1 ? headers[0].Icon : TxArrowsIcon + } +} -const pagingNav = (asset, last_seen_txid, est_curr_chain_seen_count, prev_paging_txids, next_paging_txids, prev_paging_est_count, t) => +const pagingNav = ( + asset, + last_seen_txid, + est_curr_chain_seen_count, + prev_paging_txids, + next_paging_txids, + prev_paging_est_count, + t +) => process.browser -? last_seen_txid != null && -
- {t`Load more`} -
+ ? last_seen_txid != null && +
+ {t`Load more`} +
-: [ - prev_paging_txids != null && - -
- {t`Newer`} -
- , next_paging_txids != null && - - {t`Older`} -
-
- ] + : [ + prev_paging_txids != null && + +
+ {t`Newer`} +
+ , next_paging_txids != null && + + {t`Older`} +
+
+ ] diff --git a/client/src/views/tx.js b/client/src/views/tx.js index ad269c52..263b0a5c 100644 --- a/client/src/views/tx.js +++ b/client/src/views/tx.js @@ -118,6 +118,8 @@ export const txBox = ( unblinded, listingIndex, listingTotal, + headerTitle, + HeaderIcon = TxArrowsIcon, showTxId = true, showFooter = true, ...S @@ -126,20 +128,21 @@ export const txBox = ( const vopt = { isOpen: openTx == tx.txid, query, t, ...S }; const hasListingHeader = Number.isFinite(listingIndex) && Number.isFinite(listingTotal); + const resolvedHeaderTitle = headerTitle || (hasListingHeader + ? t`${formatNumber(listingIndex)} of ${formatNumber(listingTotal)} Transactions` + : null); // Try deducing unknown blinded ins/outs (elements only) if (process.env.IS_ELEMENTS) deduceBlinded(tx); return (
- {hasListingHeader ? ( + {resolvedHeaderTitle ? (
- +
-

- {t`${formatNumber(listingIndex)} of ${formatNumber(listingTotal)} Transactions`} -

+

{resolvedHeaderTitle}

) : null}
diff --git a/test/asset-view.test.js b/test/asset-view.test.js new file mode 100644 index 00000000..36074c46 --- /dev/null +++ b/test/asset-view.test.js @@ -0,0 +1,205 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const toHTML = require("snabbdom-to-html"); + +const { + default: assetView, + assetTransactionHeader, + registryAssetUrl, +} = require("../client/src/views/asset"); +const { txBox } = require("../client/src/views/tx"); +const { TxArrowsIcon } = require("../client/src/components/icons"); + +const t = (strings, ...values) => + strings.reduce( + (translated, string, index) => + translated + string + (index < values.length ? values[index] : ""), + "", + ); + +t.langs = { en: t }; +t.lang_id = "en"; + +const hasClass = (vnode, className) => + vnode && + vnode.data && + vnode.data.props && + String(vnode.data.props.className || "") + .split(/\s+/) + .includes(className); + +const findByClass = (vnode, className) => { + if (hasClass(vnode, className)) return vnode; + + for (const child of (vnode && vnode.children) || []) { + const match = findByClass(child, className); + if (match) return match; + } +}; + +const findAllByClass = (vnode, className, matches = []) => { + if (hasClass(vnode, className)) matches.push(vnode); + for (const child of (vnode && vnode.children) || []) { + findAllByClass(child, className, matches); + } + return matches; +}; + +test("renders a custom transaction heading without listing counters", () => { + const html = toHTML( + txBox( + { txid: "asset-tx", vin: [], vout: [] }, + { + t, + query: {}, + spends: [], + headerTitle: "Burn Transaction", + showFooter: false, + }, + ), + ); + + assert.match(html, />Burn Transaction { + const asset = { + asset_id: "issued-asset", + issuance_txin: { txid: "issuance-tx", vin: 0 }, + }; + const tx = { + vin: [ + { + issuance: { + asset_id: asset.asset_id, + is_reissuance: false, + }, + }, + ], + vout: [ + { + asset: asset.asset_id, + scriptpubkey_type: "op_return", + }, + ], + }; + + const header = assetTransactionHeader(tx, asset, t); + + assert.equal(header.title, "Issuance Transaction + Burn Transaction"); + assert.equal(header.Icon, TxArrowsIcon); +}); + +test("builds registry asset URLs with exactly one path separator", () => { + assert.equal( + registryAssetUrl("asset-id"), + "https://assets.blockstream.info/asset-id", + ); + assert.equal( + registryAssetUrl("asset-id", "https://registry.example/v1/"), + "https://registry.example/v1/asset-id", + ); +}); + +test("keeps issued-asset identifiers and registry metadata in Contract", () => { + const asset = { + asset_id: "asset-id", + issuance_txin: { txid: "issuance-tx", vin: 0 }, + issuance_prevout: { txid: "previous-tx", vout: 1 }, + reissuance_token: "reissuance-token-id", + contract_hash: "contract-hash", + status: { confirmed: false }, + precision: 2, + contract: { + entity: { domain: "example.com" }, + issuer_pubkey: "issuer-pubkey", + name: "Example Asset", + precision: 2, + ticker: "EXMPL", + version: 0, + }, + chain_stats: { + tx_count: 1, + issuance_count: 1, + issued_amount: 100, + burned_amount: 0, + has_blinded_issuances: false, + reissuance_tokens: 10, + burned_reissuance_tokens: 0, + }, + mempool_stats: { + tx_count: 0, + issuance_count: 0, + issued_amount: 0, + burned_amount: 0, + has_blinded_issuances: false, + burned_reissuance_tokens: 0, + }, + }; + const page = assetView({ + t, + asset, + assetTxs: [], + goAsset: { last_txids: [], est_chain_seen_count: 0 }, + spends: [], + loading: false, + page: { pathname: "asset/asset-id", query: {} }, + }); + + const contractTable = findByClass(page, "asset-contract-table"); + const contractRows = findAllByClass(contractTable, "detail-grid-row"); + const pageHtml = toHTML(page); + + assert.equal(findByClass(page, "asset-details-table"), undefined); + assert.doesNotMatch( + pageHtml, + /href="asset\/reissuance-token-id"/, + ); + assert.equal(contractRows.length, 8); + for (const row of contractRows) { + assert.equal(findAllByClass(row, "detail-field").length, 1); + } + assert.equal(findAllByClass(contractTable, "table-copy-button").length, 1); + + const contractHtml = toHTML(contractTable); + assert.match(contractHtml, /Asset ID/); + assert.match(contractHtml, /Contract hash/); + assert.doesNotMatch(contractHtml, /Issuance transaction/); + assert.doesNotMatch(contractHtml, /Reissuance tokens/); + + const rawJsonLink = findByClass(contractTable, "asset-table-issuance-link"); + assert.equal( + rawJsonLink.data.props.href, + "https://assets.blockstream.info/asset-id", + ); +}); + +test("shows on-chain contract identifiers for unregistered assets", () => { + const asset = { + asset_id: "unregistered-asset-id", + issuance_txin: { txid: "issuance-tx", vin: 0 }, + contract_hash: "contract-hash", + status: { confirmed: false }, + chain_stats: {}, + mempool_stats: {}, + }; + const page = assetView({ + t, + asset, + assetTxs: [], + goAsset: { last_txids: [], est_chain_seen_count: 0 }, + spends: [], + loading: false, + page: { pathname: "asset/unregistered-asset-id", query: {} }, + }); + + const contractTable = findByClass(page, "asset-contract-table"); + const contractRows = findAllByClass(contractTable, "detail-grid-row"); + + assert.equal(contractRows.length, 2); + assert.equal( + findByClass(contractTable, "asset-table-issuance-link"), + undefined, + ); +}); diff --git a/www/style.css b/www/style.css index da7e40da..ab28ad27 100644 --- a/www/style.css +++ b/www/style.css @@ -135,6 +135,7 @@ html { --foreground-link-color: #00C3FF; --foreground-muted: #B5BDC2; --radius-rounded-xl: 12px; + --max-border-radius: 9999px; } body { @@ -1028,7 +1029,7 @@ table th { margin-top: var(--page-gap); } -.addr-page, .block-page, .tx-page { +.addr-page, .block-page, .tx-page, .asset-page-container { display: flex; flex-direction: column; gap: var(--page-gap); @@ -1255,10 +1256,6 @@ table th { align-items: center; } -.transaction-box > .table-header + .transaction-box-header { - margin-top: 24px; -} - .float-right { float: right; } @@ -3891,7 +3888,7 @@ a.back-link img{ box-sizing: initial; padding: 2px 8px; font-size: 11px; - border-radius: 9999px; + border-radius: var(--max-border-radius); white-space: nowrap; } @@ -3948,7 +3945,7 @@ a.back-link img{ padding: 2px 8px; font-size: 11px; background-color: var(--surface-primary-color); - border-radius: 9999px; + border-radius: var(--max-border-radius); display: flex; align-items: center; gap: 6px; @@ -3958,7 +3955,7 @@ a.back-link img{ background-color: var(--warning-color); width: 6px; height: 6px; - border-radius: 9999px; + border-radius: var(--max-border-radius); } .detail-grid { @@ -4179,7 +4176,6 @@ a.back-link img{ font-size: 16px; font-weight: 400; font-family: Inter; - cursor: pointer; } .detail-grid-row { @@ -4392,7 +4388,113 @@ a.back-link img{ color: var(--blockstream-primary-brand-color); } +.asset-table { + display: flex; + align-items: center; + gap: 16px; +} + +.asset-table-body { + display: flex; + flex-direction: column; + gap: 12px; + flex: 1; +} + +.asset-title { + font-size: 28px; + font-weight: 700; + font-family: 'Rigid Square'; + overflow-wrap: anywhere; +} + +.asset-title-row { + display: flex; + align-items: center; + gap: 8px; +} + +.asset-icon-container { + width: 80px; + height: 80px; + background-color: var(--surface-secondary-color); + border-radius: var(--max-border-radius); + display: flex; + justify-content: center; + align-items: center; + color: var(--accent-color); +} + +.asset-table-details { + display: flex; + align-items: center; + gap: 24px; +} + +.asset-table-stats { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.asset-table-issuance-link { + margin-left: auto; + font-size: 16px; + font-family: 'Rigid Square'; + font-weight: 700; + display: flex; + align-items: center; + gap: 10px; + flex-shrink: 0; +} + +.asset-table-issuance-link p { + margin-top: 2px; + line-height: 22px; +} + +.asset-contract-table { + display: flex; + flex-direction: column; + gap: 12px; +} + +.asset-contract-table .detail-field, +.asset-contract-table .detail-field-content, +.asset-contract-table .detail-field-content > p { + min-width: 0; +} + +.asset-contract-table .detail-field-content > p { + overflow-wrap: anywhere; +} + +.asset-page-container > .transactions { + margin-top: 0; +} + @media only screen and (max-width: 820px) { + .asset-table { + align-items: flex-start; + } + + .asset-table-details { + align-items: flex-start; + flex-direction: column; + } + + .asset-table-issuance-link { + margin-left: 0; + } + + .asset-contract-table .detail-grid-row { + flex-direction: column; + } + + .asset-contract-table .detail-field { + width: 100%; + } + .block-details-card-summary { flex-direction: column; }