const $ = (selector) => document.querySelector(selector); const view = $('#view'); const content = $('#content'); const number = new Intl.NumberFormat('en-GB'); const atomic = 100_000_000_000; let chainInfo = null; const escapeHtml = (value) => String(value ?? '').replace(/[&<>'"]/g, (char) => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[char])); const shortHash = (hash, size = 12) => hash ? `${hash.slice(0, size)}…${hash.slice(-8)}` : '—'; const xmz = (value) => `${(Number(value || 0) / atomic).toLocaleString('en-GB', {maximumFractionDigits: 11})} XMZ`; const date = (timestamp) => timestamp ? new Date(timestamp * 1000).toLocaleString() : '—'; const age = (timestamp) => { const seconds = Math.max(0, Math.floor(Date.now() / 1000 - timestamp)); if (seconds < 60) return `${seconds}s ago`; if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; return `${Math.floor(seconds / 86400)}d ago`; }; async function api(action, data = {}) { const response = await fetch('api.php', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({action, ...data})}); const result = await response.json().catch(() => ({})); if (!response.ok || result.error) throw new Error(result.error || `HTTP ${response.status}`); return result; } function loading(label = 'Loading chain data…') { view.innerHTML = `
${escapeHtml(label)}
`; } function showError(message = '') { const fragment = $('#error-template').content.cloneNode(true); if (message) fragment.querySelector('p').textContent = message; view.replaceChildren(fragment); } function updateStats(info) { chainInfo = info; $('#stat-height').textContent = number.format(info.height || 0); $('#stat-difficulty').textContent = number.format(info.difficulty || 0); $('#stat-hashrate').textContent = `${number.format(Math.round((info.difficulty || 0) / (info.target || 120)))} H/s estimated`; $('#stat-transactions').textContent = number.format(info.tx_count || 0); $('#stat-connections').textContent = number.format((info.incoming_connections_count || 0) + (info.outgoing_connections_count || 0)); $('#node-label').textContent = info.status === 'OK' ? 'Node online' : info.status; $('#node-dot').className = info.status === 'OK' ? 'online' : 'offline'; } function headerTitle(kicker, title) { $('.section-title').innerHTML = `

${escapeHtml(kicker)}

${title}

← Recent blocks`; } async function showBlocks() { loading(); $('.section-title').innerHTML = `

Live ledger

Recent blocks

`; try { const result = await api('blocks', {limit: 15}); updateStats(result.info); view.innerHTML = `
${result.headers.map((block) => ` `).join('')}
HeightAgeBlock hashTransactionsDifficultyRewardSize
${number.format(block.height)}${age(block.timestamp)}${shortHash(block.hash)}${number.format(block.num_txes || 0)}${number.format(block.difficulty || 0)}${xmz(block.reward)}${number.format(block.block_size || block.block_weight || 0)} B
`; $('#refresh').addEventListener('click', showBlocks); } catch (error) { showError(error.message); setOffline(); } } async function showBlock(value) { loading('Loading block…'); try { const input = /^\d+$/.test(value) ? {height:Number(value)} : {hash:value}; const block = await api('block', input); const h = block.block_header || {}; headerTitle('Block', `#${number.format(h.height ?? input.height)}`); const txs = block.tx_hashes || []; view.innerHTML = `
${datum('Block hash', `${escapeHtml(h.hash)}`)} ${datum('Timestamp', `${escapeHtml(date(h.timestamp))}`)} ${datum('Age', `${escapeHtml(age(h.timestamp))}`)} ${datum('Reward', `${xmz(h.reward)}`)} ${datum('Difficulty', `${number.format(h.difficulty || 0)}`)} ${datum('Transactions', `${number.format(h.num_txes || txs.length)}`)} ${datum('Block size', `${number.format(h.block_size || h.block_weight || 0)} bytes`)} ${datum('Nonce', `${number.format(h.nonce || 0)}`)} ${datum('Version', `v${h.major_version ?? '—'}.${h.minor_version ?? '—'}`)} ${datum('Previous block', h.prev_hash ? `${shortHash(h.prev_hash)}` : 'Genesis')} ${datum('Proof-of-work hash', `${escapeHtml(h.pow_hash || 'Not requested')}`)} ${datum('Confirmations', `${chainInfo ? number.format(Math.max(0, chainInfo.height - h.height)) : '—'}`)}

Transactions in this block

${txs.length ? `
${txs.map((hash, i) => ``).join('')}
#Transaction hash
${i + 1}${hash}
` : '
Coinbase only

This block contains no regular transactions.

'}
Raw block JSON
${escapeHtml(JSON.stringify(block, null, 2))}
`; } catch (error) { await tryTransaction(value, error); } } async function tryTransaction(value, originalError) { if (!/^[0-9a-f]{64}$/i.test(value)) return showError(originalError.message); try { await showTransaction(value); } catch { showError('No block or transaction matched this hash.'); } } async function showTransaction(hash) { loading('Loading transaction…'); const result = await api('transaction', {hash}); const tx = result.txs?.[0] || {}; let decoded = {}; try { decoded = JSON.parse(tx.as_json || result.txs_as_json?.[0] || '{}'); } catch {} headerTitle('Transaction', `${escapeHtml(shortHash(hash, 16))}`); const vin = decoded.vin || []; const vout = decoded.vout || []; view.innerHTML = `
${datum('Transaction hash', `${escapeHtml(hash)}`)} ${datum('Block height', tx.block_height != null ? `${number.format(tx.block_height)}` : 'Unconfirmed')} ${datum('Confirmations', `${number.format(tx.confirmations || 0)}`)} ${datum('Fee', `${xmz(tx.fee || decoded.rct_signatures?.txnFee || 0)}`)} ${datum('Size', `${number.format(tx.size || tx.weight || 0)} bytes`)} ${datum('Version', `${decoded.version ?? '—'}`)} ${datum('Inputs', `${number.format(vin.length)}`)} ${datum('Outputs', `${number.format(vout.length)}`)} ${datum('In pool', `${tx.in_pool ? 'Yes' : 'No'}`)}
Decoded transaction JSON
${escapeHtml(JSON.stringify(decoded, null, 2))}
`; } function datum(label, value) { return `
${escapeHtml(label)}${value}
`; } function setOffline() { $('#node-dot').className = 'offline'; $('#node-label').textContent = 'Node unavailable'; } async function route() { const route = location.hash.replace(/^#\/?/, '').split('/').filter(Boolean); if (location.hash) content.scrollIntoView({behavior:'smooth', block:'start'}); if (!route.length) return showBlocks(); if (route[0] === 'block' && route[1]) return showBlock(route[1]); if (route[0] === 'tx' && route[1]) { try { return await showTransaction(route[1]); } catch (error) { return showError(error.message); } } showError(); } $('#search-form').addEventListener('submit', (event) => { event.preventDefault(); const value = $('#search-input').value.trim(); if (/^\d+$/.test(value) || /^[0-9a-f]{64}$/i.test(value)) location.hash = `#/block/${value}`; else showError('Enter a numeric block height or a 64-character hexadecimal hash.'); }); $('#latest-search').addEventListener('click', () => { if (chainInfo?.height) location.hash = `#/block/${chainInfo.height - 1}`; }); window.addEventListener('hashchange', route); api('info').then(updateStats).catch(setOffline); route(); setInterval(() => api('info').then(updateStats).catch(setOffline), 30000);