-
-
{(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);
+ assert.doesNotMatch(html, /undefined/);
+});
+
+test("combines every applicable asset transaction classification", () => {
+ 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;
}