2025-12-19 10:11:49 +01:00

430 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// static/pages/TransactionDetail.js
import { h, Fragment } from 'preact';
import { useEffect, useMemo, useState } from 'preact/hooks';
import { API, PAGE } from '../lib/api.js';
import { shortenHex } from '../lib/utils.js';
// ————— helpers —————
const isNumber = (v) => typeof v === 'number' && !Number.isNaN(v);
const toLocaleNum = (n, opts = {}) => Number(n ?? 0).toLocaleString(undefined, { maximumFractionDigits: 8, ...opts });
// Best-effort pretty bytes/hex/string
function renderBytes(value) {
if (value == null) return '';
if (typeof value === 'string') return value; // hex/base64/plain
if (Array.isArray(value) && value.every((x) => Number.isInteger(x) && x >= 0 && x <= 255)) {
return '0x' + value.map((b) => b.toString(16).padStart(2, '0')).join('');
}
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
const opLabel = (op) => {
if (op == null) return 'op';
if (typeof op === 'string' || typeof op === 'number') return String(op);
if (typeof op !== 'object') return String(op);
if (typeof op.type === 'string') return op.type;
if (typeof op.kind === 'string') return op.kind;
if (op.content) {
if (typeof op.content.type === 'string') return op.content.type;
if (typeof op.content.kind === 'string') return op.content.kind;
}
const keys = Object.keys(op);
return keys.length ? keys[0] : 'op';
};
function opsToPills(ops, limit = 6) {
const arr = Array.isArray(ops) ? ops : [];
if (!arr.length) return h('span', { style: 'color:var(--muted); whiteSpace: "nowrap";' }, '—');
const labels = arr.map(opLabel);
const shown = labels.slice(0, limit);
const extra = labels.length - shown.length;
return h(
'div',
{ style: 'display:flex; gap:6px; flexWrap:"wrap"; alignItems:"center"' },
...shown.map((label, i) =>
h('span', { key: `${label}-${i}`, class: 'pill', title: label, style: 'flex:0 0 auto;' }, label),
),
extra > 0 && h('span', { class: 'pill', title: `${extra} more`, style: 'flex:0 0 auto;' }, `+${extra}`),
);
}
const toNumber = (v) => {
if (v == null) return 0;
if (typeof v === 'number') return v;
if (typeof v === 'bigint') return Number(v);
if (typeof v === 'string') {
const s = v.trim();
if (/^0x[0-9a-f]+$/i.test(s)) return Number(BigInt(s));
const n = Number(s);
return Number.isFinite(n) ? n : 0;
}
if (typeof v === 'object' && v !== null && 'value' in v) return toNumber(v.value);
return 0;
};
function CopyPill({ text, label = 'Copy' }) {
const onCopy = async (e) => {
e.preventDefault();
try {
await navigator.clipboard.writeText(String(text ?? ''));
} catch {}
};
return h(
'a',
{
class: 'pill linkish mono',
style: 'cursor:pointer; user-select:none;',
href: '#',
onClick: onCopy,
onKeyDown: (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onCopy(e);
}
},
tabIndex: 0,
role: 'button',
},
label,
);
}
// ————— normalizer for new TransactionRead —————
// { id, block_id, hash, operations:[Operation], inputs:[HexBytes], outputs:[Note{public_key:HexBytes,value:int}],
// proof, execution_gas_price, storage_gas_price }
function normalizeTransaction(raw) {
const ops = Array.isArray(raw?.operations) ? raw.operations : Array.isArray(raw?.ops) ? raw.ops : [];
const inputs = Array.isArray(raw?.inputs) ? raw.inputs : [];
const outputs = Array.isArray(raw?.outputs) ? raw.outputs : [];
const totalOutputValue = outputs.reduce((sum, note) => sum + toNumber(note?.value), 0);
return {
id: raw?.id ?? '',
blockHash: raw?.block_hash ?? null,
hash: renderBytes(raw?.hash),
proof: renderBytes(raw?.proof),
operations: ops, // keep objects, well label in UI
executionGasPrice: isNumber(raw?.execution_gas_price)
? raw.execution_gas_price
: toNumber(raw?.execution_gas_price),
storageGasPrice: isNumber(raw?.storage_gas_price) ? raw.storage_gas_price : toNumber(raw?.storage_gas_price),
ledger: {
inputs: inputs.map((v) => renderBytes(v)),
outputs: outputs.map((n) => ({
public_key: renderBytes(n?.public_key),
value: toNumber(n?.value),
})),
totalOutputValue,
},
};
}
// ————— UI bits —————
function SectionCard({ title, children, style }) {
return h(
'div',
{ class: 'card', style: `margin-top:12px; ${style ?? ''}` },
h('div', { class: 'card-header' }, h('strong', null, title)),
h('div', { style: 'padding:12px 14px' }, children),
);
}
function Summary({ tx }) {
return h(
SectionCard,
{ title: 'Summary' },
h(
'div',
{ style: 'display:grid; gap:8px;' },
// Block link
tx.blockHash != null &&
h(
'div',
null,
h('b', null, 'Block: '),
h(
'a',
{ class: 'linkish mono', href: PAGE.BLOCK_DETAIL(tx.blockHash), title: String(tx.blockHash) },
shortenHex(tx.blockHash),
),
),
// Hash + copy
h(
'div',
null,
h('b', null, 'Hash: '),
h(
'span',
{ class: 'pill mono', title: tx.hash, style: 'max-width:100%; overflow-wrap:anywhere;' },
String(tx.hash || ''),
),
h(CopyPill, { text: tx.hash }),
),
// Proof + copy (if present)
tx.proof &&
h(
'div',
null,
h('b', null, 'Proof: '),
h(
'span',
{ class: 'pill mono', title: tx.proof, style: 'max-width:100%; overflow-wrap:anywhere;' },
String(tx.proof),
),
h(CopyPill, { text: tx.proof }),
),
// Gas
h(
'div',
null,
h('b', null, 'Execution Gas: '),
h('span', { class: 'mono' }, toLocaleNum(tx.executionGasPrice)),
),
h(
'div',
null,
h('b', null, 'Storage Gas: '),
h('span', { class: 'mono' }, toLocaleNum(tx.storageGasPrice)),
),
// Operations (labels as pills)
h('div', null, h('b', null, 'Operations: '), opsToPills(tx.operations)),
),
);
}
function InputsTable({ inputs }) {
if (!inputs?.length) return h('div', { style: 'color:var(--muted)' }, '—');
return h(
'div',
{ class: 'table-wrapper', style: 'margin-top:6px;' },
h(
'table',
{ class: 'table--transactions' },
h(
'colgroup',
null,
h('col', { style: 'width:80px' }), // #
h('col', null), // Value
),
h('thead', null, h('tr', null, h('th', { style: 'text-align:center;' }, '#'), h('th', null, 'Value'))),
h(
'tbody',
null,
...inputs.map((fr, i) =>
h(
'tr',
{ key: i },
h('td', { style: 'text-align:center;' }, String(i)),
h(
'td',
null,
h(
'span',
{ class: 'mono', style: 'overflow-wrap:anywhere; word-break:break-word;' },
String(fr),
),
),
),
),
),
),
);
}
function OutputsTable({ outputs }) {
if (!outputs?.length) return h('div', { style: 'color:var(--muted)' }, '—');
return h(
'div',
{ class: 'table-wrapper', style: 'margin-top:6px;' },
h(
'table',
{ class: 'table--transactions' },
h(
'colgroup',
null,
h('col', { style: 'width:80px' }), // #
h('col', null), // Public Key
h('col', { style: 'width:180px' }), // Value
),
h(
'thead',
null,
h(
'tr',
null,
h('th', { style: 'text-align:center;' }, '#'),
h('th', null, 'Public Key'),
h('th', { style: 'text-align:right;' }, 'Value'),
),
),
h(
'tbody',
null,
...outputs.map((note, idx) =>
h(
'tr',
{ key: idx },
h('td', { style: 'text-align:center;' }, String(idx)),
h(
'td',
null,
h(
'span',
{ class: 'mono', style: 'display:inline-block; overflow-wrap:anywhere;' },
String(note.public_key ?? ''),
),
h('span', { class: 'sr-only' }, ' '),
),
h('td', { class: 'amount', style: 'text-align:right;' }, toLocaleNum(note.value)),
),
),
),
),
);
}
function Ledger({ ledger }) {
const inputs = Array.isArray(ledger?.inputs) ? ledger.inputs : [];
const outputs = Array.isArray(ledger?.outputs) ? ledger.outputs : [];
const totalInputValue = inputs.reduce((s, v) => s + toNumber(v), 0);
const totalOutputValue = toNumber(ledger?.totalOutputValue);
return h(
SectionCard,
{ title: 'Ledger' },
h(
'div',
{ style: 'display:grid; gap:16px;' },
// Inputs
h(
'div',
null,
h(
'div',
{ style: 'display:flex; alignItems:center; gap:8px;' },
h('b', null, 'Inputs'),
h('span', { class: 'pill' }, String(inputs.length)),
h(
'span',
{ class: 'amount', style: 'margin-left:auto;' },
`Total: ${toLocaleNum(totalInputValue)}`,
),
),
h(InputsTable, { inputs }),
),
// Outputs
h(
'div',
null,
h(
'div',
{ style: 'display:flex; alignItems:center; gap:8px;' },
h('b', null, 'Outputs'),
h('span', { class: 'pill' }, String(outputs.length)),
h(
'span',
{ class: 'amount', style: 'margin-left:auto;' },
`Total: ${toLocaleNum(totalOutputValue)}`,
),
),
h(OutputsTable, { outputs }),
),
),
);
}
// ————— page —————
export default function TransactionDetail({ parameters }) {
const transactionHash = parameters?.[0];
const isValidHash = typeof transactionHash === 'string' && transactionHash.length > 0;
const [tx, setTx] = useState(null);
const [err, setErr] = useState(null); // { kind: 'invalid'|'not-found'|'network', msg: string }
const pageTitle = useMemo(() => `Transaction ${shortenHex(transactionHash)}`, [transactionHash]);
useEffect(() => {
document.title = pageTitle;
}, [pageTitle]);
useEffect(() => {
setTx(null);
setErr(null);
if (!isValidHash) {
setErr({ kind: 'invalid', msg: 'Invalid transaction hash.' });
return;
}
let alive = true;
const controller = new AbortController();
(async () => {
try {
const res = await fetch(API.TRANSACTION_DETAIL_BY_HASH(transactionHash), {
cache: 'no-cache',
signal: controller.signal,
});
if (res.status === 404 || res.status === 410) {
if (alive) setErr({ kind: 'not-found', msg: 'Transaction not found.' });
return;
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const payload = await res.json();
if (!alive) return;
setTx(normalizeTransaction(payload));
} catch (e) {
if (!alive || e?.name === 'AbortError') return;
setErr({ kind: 'network', msg: e?.message ?? 'Failed to load transaction' });
}
})();
return () => {
alive = false;
controller.abort();
};
}, [transactionHash, isValidHash]);
return h(
'main',
{ class: 'wrap' },
h(
'header',
{ style: 'display:flex; gap:12px; alignItems:center; margin:12px 0;' },
h('a', { class: 'linkish', href: '/' }, '← Back'),
h('h1', { style: 'margin:0' }, pageTitle),
),
// Errors
err?.kind === 'invalid' && h('p', { style: 'color:#ff8a8a' }, err.msg),
err?.kind === 'not-found' &&
h(
SectionCard,
{ title: 'Transaction not found' },
h('p', null, 'We could not find a transaction with that identifier.'),
),
err?.kind === 'network' && h('p', { style: 'color:#ff8a8a' }, `Error: ${err.msg}`),
// Loading
!tx && !err && h('p', null, 'Loading…'),
// Success
tx && h(Fragment, null, h(Summary, { tx }), h(Ledger, { ledger: tx.ledger })),
);
}