aboutsummaryrefslogtreecommitdiff
path: root/explorer
diff options
context:
space:
mode:
Diffstat (limited to 'explorer')
-rw-r--r--explorer/README.md26
-rw-r--r--explorer/api.php126
-rw-r--r--explorer/app.js140
-rw-r--r--explorer/index.html59
-rw-r--r--explorer/styles.css73
5 files changed, 424 insertions, 0 deletions
diff --git a/explorer/README.md b/explorer/README.md
new file mode 100644
index 000000000..87432e85d
--- /dev/null
+++ b/explorer/README.md
@@ -0,0 +1,26 @@
+# Monzero block explorer
+
+A dependency-free PHP and JavaScript explorer for the Monzero restricted RPC.
+It is read-only and never handles wallet seeds, private keys, or passwords.
+
+## Run locally
+
+Requirement: PHP 8. The API uses cURL when available and otherwise falls back
+to PHP's native HTTP streams.
+
+```bash
+cd explorer
+php -S 127.0.0.1:8081
+```
+
+Open `http://127.0.0.1:8081`.
+
+The RPC URL is defined by `NODE_RPC` at the top of `api.php`. It currently uses
+the public node at `http://node.monzero.org:6175`.
+
+## Later deployment
+
+Upload the directory to a PHP-enabled web root or subdomain. For production,
+place it at a dedicated hostname such as `explorer.monzero.org`, enable HTTPS,
+and retain a restricted node RPC. The PHP endpoint uses an action allowlist and
+does not expose a generic RPC proxy.
diff --git a/explorer/api.php b/explorer/api.php
new file mode 100644
index 000000000..109485dab
--- /dev/null
+++ b/explorer/api.php
@@ -0,0 +1,126 @@
+<?php
+declare(strict_types=1);
+
+const NODE_RPC = 'http://node.monzero.org:6175';
+const MAX_RANGE = 25;
+
+header('Content-Type: application/json; charset=utf-8');
+header('Cache-Control: no-store');
+header('X-Content-Type-Options: nosniff');
+
+if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
+ respond(['error' => 'POST required'], 405);
+}
+
+$input = json_decode(file_get_contents('php://input'), true);
+if (!is_array($input)) respond(['error' => 'Invalid JSON'], 400);
+$action = $input['action'] ?? '';
+
+try {
+ switch ($action) {
+ case 'info':
+ respond(rpc('/get_info', []));
+
+ case 'blocks':
+ $info = rpc('/get_info', []);
+ $height = max(1, (int)($info['height'] ?? 1));
+ $limit = min(MAX_RANGE, max(1, (int)($input['limit'] ?? 15)));
+ $end = max(0, $height - 1);
+ $start = max(0, $end - $limit + 1);
+ $range = jsonRpc('get_block_headers_range', [
+ 'start_height' => $start,
+ 'end_height' => $end,
+ 'fill_pow_hash' => false,
+ ]);
+ respond(['info' => $info, 'headers' => array_reverse($range['headers'] ?? [])]);
+
+ case 'block':
+ $params = ['fill_pow_hash' => true];
+ if (isset($input['height']) && is_numeric($input['height'])) {
+ $params['height'] = max(0, (int)$input['height']);
+ } elseif (validHash($input['hash'] ?? '')) {
+ $params['hash'] = strtolower($input['hash']);
+ } else {
+ respond(['error' => 'Valid height or block hash required'], 400);
+ }
+ respond(jsonRpc('get_block', $params));
+
+ case 'transaction':
+ $hash = strtolower((string)($input['hash'] ?? ''));
+ if (!validHash($hash)) respond(['error' => 'Valid transaction hash required'], 400);
+ $result = rpc('/get_transactions', [
+ 'txs_hashes' => [$hash],
+ 'decode_as_json' => true,
+ 'prune' => false,
+ 'split' => true,
+ ]);
+ if (empty($result['txs']) && empty($result['txs_as_json'])) respond(['error' => 'Transaction not found'], 404);
+ respond($result);
+
+ case 'pool':
+ respond(rpc('/get_transaction_pool', []));
+
+ default:
+ respond(['error' => 'Unknown action'], 400);
+ }
+} catch (Throwable $error) {
+ error_log('Monzero explorer API: ' . $error->getMessage());
+ respond(['error' => 'Node request failed'], 502);
+}
+
+function validHash(mixed $value): bool {
+ return is_string($value) && preg_match('/^[0-9a-fA-F]{64}$/', $value) === 1;
+}
+
+function jsonRpc(string $method, array $params): array {
+ $response = rpc('/json_rpc', [
+ 'jsonrpc' => '2.0',
+ 'id' => '0',
+ 'method' => $method,
+ 'params' => $params,
+ ]);
+ if (isset($response['error'])) throw new RuntimeException($response['error']['message'] ?? 'RPC error');
+ return $response['result'] ?? [];
+}
+
+function rpc(string $path, array $payload): array {
+ // Daemon endpoints expect an empty JSON object, not an empty JSON array.
+ $json = $payload === [] ? '{}' : json_encode($payload, JSON_THROW_ON_ERROR);
+ if (function_exists('curl_init')) {
+ $curl = curl_init(NODE_RPC . $path);
+ curl_setopt_array($curl, [
+ CURLOPT_POST => true,
+ CURLOPT_POSTFIELDS => $json,
+ CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_CONNECTTIMEOUT => 3,
+ CURLOPT_TIMEOUT => 12,
+ ]);
+ $body = curl_exec($curl);
+ $status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
+ $message = curl_error($curl);
+ curl_close($curl);
+ if ($body === false || $status !== 200) throw new RuntimeException($message ?: 'HTTP ' . $status);
+ } else {
+ $context = stream_context_create(['http' => [
+ 'method' => 'POST',
+ 'header' => "Content-Type: application/json\r\nConnection: close\r\n",
+ 'content' => $json,
+ 'timeout' => 12,
+ 'ignore_errors' => true,
+ ]]);
+ $body = @file_get_contents(NODE_RPC . $path, false, $context);
+ $statusLine = $http_response_header[0] ?? '';
+ preg_match('/\s(\d{3})\s/', $statusLine, $match);
+ $status = isset($match[1]) ? (int)$match[1] : 0;
+ if ($body === false || $status !== 200) throw new RuntimeException('HTTP ' . $status);
+ }
+ $decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
+ return is_array($decoded) ? $decoded : [];
+}
+
+function respond(array $data, int $status = 200): never {
+ http_response_code($status);
+ echo json_encode($data, JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE);
+ exit;
+}
diff --git a/explorer/app.js b/explorer/app.js
new file mode 100644
index 000000000..ab89b3be0
--- /dev/null
+++ b/explorer/app.js
@@ -0,0 +1,140 @@
+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) => ({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[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 = `<div class="loading"><span></span>${escapeHtml(label)}</div>`; }
+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 = `<div><p class="eyebrow">${escapeHtml(kicker)}</p><h2>${title}</h2></div><a class="back" href="#/">← Recent blocks</a>`;
+}
+
+async function showBlocks() {
+ loading();
+ $('.section-title').innerHTML = `<div><p class="eyebrow">Live ledger</p><h2>Recent blocks</h2></div><button id="refresh" class="ghost" type="button">Refresh</button>`;
+ try {
+ const result = await api('blocks', {limit: 15});
+ updateStats(result.info);
+ view.innerHTML = `<div class="table-wrap"><table><thead><tr><th>Height</th><th>Age</th><th>Block hash</th><th>Transactions</th><th>Difficulty</th><th>Reward</th><th>Size</th></tr></thead><tbody>${result.headers.map((block) => `
+ <tr><td><a class="link" href="#/block/${block.height}">${number.format(block.height)}</a></td><td class="muted">${age(block.timestamp)}</td><td><a class="link hash truncate" title="${block.hash}" href="#/block/${block.hash}">${shortHash(block.hash)}</a></td><td>${number.format(block.num_txes || 0)}</td><td class="muted">${number.format(block.difficulty || 0)}</td><td class="reward">${xmz(block.reward)}</td><td class="muted">${number.format(block.block_size || block.block_weight || 0)} B</td></tr>`).join('')}</tbody></table></div>`;
+ $('#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 = `<div class="detail-grid">
+ ${datum('Block hash', `<code>${escapeHtml(h.hash)}</code>`)}
+ ${datum('Timestamp', `<strong>${escapeHtml(date(h.timestamp))}</strong>`)}
+ ${datum('Age', `<strong>${escapeHtml(age(h.timestamp))}</strong>`)}
+ ${datum('Reward', `<strong class="big">${xmz(h.reward)}</strong>`)}
+ ${datum('Difficulty', `<strong>${number.format(h.difficulty || 0)}</strong>`)}
+ ${datum('Transactions', `<strong>${number.format(h.num_txes || txs.length)}</strong>`)}
+ ${datum('Block size', `<strong>${number.format(h.block_size || h.block_weight || 0)} bytes</strong>`)}
+ ${datum('Nonce', `<strong>${number.format(h.nonce || 0)}</strong>`)}
+ ${datum('Version', `<strong>v${h.major_version ?? '—'}.${h.minor_version ?? '—'}</strong>`)}
+ ${datum('Previous block', h.prev_hash ? `<a class="link hash truncate" href="#/block/${h.prev_hash}">${shortHash(h.prev_hash)}</a>` : '<strong>Genesis</strong>')}
+ ${datum('Proof-of-work hash', `<code>${escapeHtml(h.pow_hash || 'Not requested')}</code>`)}
+ ${datum('Confirmations', `<strong>${chainInfo ? number.format(Math.max(0, chainInfo.height - h.height)) : '—'}</strong>`)}
+ </div>
+ <h3 class="subheading">Transactions in this block</h3>
+ ${txs.length ? `<div class="table-wrap"><table><thead><tr><th>#</th><th>Transaction hash</th></tr></thead><tbody>${txs.map((hash, i) => `<tr><td class="muted">${i + 1}</td><td><a class="link hash" href="#/tx/${hash}">${hash}</a></td></tr>`).join('')}</tbody></table></div>` : '<div class="empty"><strong>Coinbase only</strong><p>This block contains no regular transactions.</p></div>'}
+ <details class="raw"><summary>Raw block JSON</summary><pre>${escapeHtml(JSON.stringify(block, null, 2))}</pre></details>`;
+ } 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', `<span class="truncate" title="${escapeHtml(hash)}">${escapeHtml(shortHash(hash, 16))}</span>`);
+ const vin = decoded.vin || [];
+ const vout = decoded.vout || [];
+ view.innerHTML = `<div class="detail-grid">
+ ${datum('Transaction hash', `<code>${escapeHtml(hash)}</code>`)}
+ ${datum('Block height', tx.block_height != null ? `<a class="link" href="#/block/${tx.block_height}">${number.format(tx.block_height)}</a>` : '<strong>Unconfirmed</strong>')}
+ ${datum('Confirmations', `<strong>${number.format(tx.confirmations || 0)}</strong>`)}
+ ${datum('Fee', `<strong class="orange">${xmz(tx.fee || decoded.rct_signatures?.txnFee || 0)}</strong>`)}
+ ${datum('Size', `<strong>${number.format(tx.size || tx.weight || 0)} bytes</strong>`)}
+ ${datum('Version', `<strong>${decoded.version ?? '—'}</strong>`)}
+ ${datum('Inputs', `<strong>${number.format(vin.length)}</strong>`)}
+ ${datum('Outputs', `<strong>${number.format(vout.length)}</strong>`)}
+ ${datum('In pool', `<strong>${tx.in_pool ? 'Yes' : 'No'}</strong>`)}
+ </div><details class="raw" open><summary>Decoded transaction JSON</summary><pre>${escapeHtml(JSON.stringify(decoded, null, 2))}</pre></details>`;
+}
+
+function datum(label, value) { return `<div class="datum"><span>${escapeHtml(label)}</span>${value}</div>`; }
+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);
diff --git a/explorer/index.html b/explorer/index.html
new file mode 100644
index 000000000..63a6e54b4
--- /dev/null
+++ b/explorer/index.html
@@ -0,0 +1,59 @@
+<!doctype html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <meta name="description" content="Monzero blockchain explorer">
+ <meta name="theme-color" content="#090d0c">
+ <title>Monzero Explorer</title>
+ <link rel="stylesheet" href="styles.css">
+</head>
+<body>
+ <header>
+ <a class="brand" href="#/">
+ <span class="mark">MZ</span>
+ <span><strong>MONZERO</strong><small>CHAIN EXPLORER</small></span>
+ </a>
+ <div class="node-pill"><span id="node-dot"></span><span id="node-label">Connecting</span></div>
+ </header>
+
+ <main>
+ <section class="hero">
+ <p class="eyebrow">Independent chain · XMZ</p>
+ <h1>Verify the chain.<br><em>Block by block.</em></h1>
+ <form id="search-form" class="search">
+ <label class="sr-only" for="search-input">Search the blockchain</label>
+ <input id="search-input" autocomplete="off" spellcheck="false" placeholder="Block height, block hash, or transaction hash">
+ <button type="submit">Search <span>↗</span></button>
+ </form>
+ <p class="search-hint">Try a block height such as <button type="button" id="latest-search">latest</button> or paste a 64-character hash.</p>
+ </section>
+
+ <section id="overview" class="overview" aria-label="Network overview">
+ <article><span>Chain height</span><strong id="stat-height">—</strong><small>Mainnet</small></article>
+ <article><span>Difficulty</span><strong id="stat-difficulty">—</strong><small id="stat-hashrate">— H/s estimated</small></article>
+ <article><span>Transactions</span><strong id="stat-transactions">—</strong><small>Confirmed on-chain</small></article>
+ <article><span>Connections</span><strong id="stat-connections">—</strong><small>Public seed node</small></article>
+ </section>
+
+ <section id="content" class="content" aria-live="polite">
+ <div class="section-title">
+ <div><p class="eyebrow">Live ledger</p><h2>Recent blocks</h2></div>
+ <button id="refresh" class="ghost" type="button">Refresh</button>
+ </div>
+ <div id="view"><div class="loading"><span></span>Loading chain data…</div></div>
+ </section>
+ </main>
+
+ <footer>
+ <span>Monzero Genesis network</span>
+ <span>Node: <code>node.monzero.org:6175</code></span>
+ <span>Read-only explorer · <a href="https://monzero.org">monzero.org</a></span>
+ </footer>
+
+ <template id="error-template">
+ <div class="empty"><strong>Nothing found</strong><p>The value could not be resolved on the current Monzero chain.</p><a href="#/">Return to recent blocks</a></div>
+ </template>
+ <script src="app.js"></script>
+</body>
+</html>
diff --git a/explorer/styles.css b/explorer/styles.css
new file mode 100644
index 000000000..c7cf1f573
--- /dev/null
+++ b/explorer/styles.css
@@ -0,0 +1,73 @@
+@import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Manrope:wght@400;500;600;700&display=swap');
+
+:root { --bg:#090d0c; --panel:#0e1513; --panel2:#111a17; --line:#23312d; --text:#ecf5f1; --muted:#789087; --green:#b9ff66; --mint:#67e7b1; --orange:#ed8547; --mono:"DM Mono",monospace; --sans:"Manrope",sans-serif; }
+* { box-sizing:border-box; }
+html { scroll-behavior:smooth; }
+body { margin:0; background:var(--bg); color:var(--text); font-family:var(--sans); min-height:100vh; }
+body::before { content:""; position:fixed; inset:0; pointer-events:none; opacity:.16; background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.8' numOctaves='4'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.12'/%3E%3C/svg%3E"); z-index:10; }
+a { color:inherit; }
+button,input { font:inherit; }
+header { height:78px; display:flex; align-items:center; justify-content:space-between; padding:0 clamp(1.2rem,5vw,5rem); border-bottom:1px solid var(--line); }
+.brand { display:flex; gap:.75rem; align-items:center; color:inherit; text-decoration:none; }
+.mark { width:34px; height:34px; display:grid; place-items:center; border:1px solid var(--green); border-radius:50%; color:var(--green); font:500 .65rem var(--mono); }
+.brand > span:last-child { display:flex; flex-direction:column; line-height:1.15; letter-spacing:.16em; font-size:.75rem; }
+.brand small { color:var(--muted); font:400 .48rem var(--mono); margin-top:.22rem; }
+.node-pill { display:flex; align-items:center; gap:.6rem; border:1px solid var(--line); border-radius:2rem; padding:.5rem .8rem; color:var(--muted); font:400 .6rem var(--mono); text-transform:uppercase; letter-spacing:.08em; }
+#node-dot { width:7px; height:7px; border-radius:50%; background:#e7b358; box-shadow:0 0 10px currentColor; }
+#node-dot.online { background:var(--green); }
+#node-dot.offline { background:#ee6c57; }
+main { width:min(1480px,100%); margin:0 auto; }
+.hero { padding:clamp(4rem,8vw,7rem) clamp(1.2rem,5vw,5rem) clamp(3rem,6vw,5rem); border-bottom:1px solid var(--line); position:relative; overflow:hidden; }
+.hero::after { content:""; width:560px; height:560px; border:1px solid rgba(185,255,102,.12); border-radius:50%; position:absolute; right:-160px; top:-270px; box-shadow:0 0 100px rgba(103,231,177,.05); }
+.eyebrow { color:var(--mint); font:500 .6rem var(--mono); letter-spacing:.18em; text-transform:uppercase; margin:0 0 1rem; }
+h1 { margin:0 0 2.5rem; font-size:clamp(2.7rem,6vw,6rem); line-height:.96; letter-spacing:-.06em; font-weight:500; }
+h1 em { color:var(--green); font-style:normal; }
+.search { max-width:970px; display:grid; grid-template-columns:1fr auto; border:1px solid #344940; position:relative; z-index:1; background:rgba(11,17,15,.8); }
+.search:focus-within { border-color:var(--mint); box-shadow:0 0 0 3px rgba(103,231,177,.08); }
+.search input { min-width:0; height:62px; border:0; outline:0; background:transparent; color:var(--text); padding:0 1.2rem; font:400 .78rem var(--mono); }
+.search button { border:0; margin:5px; padding:0 1.35rem; background:var(--green); color:#08100d; font-weight:700; cursor:pointer; }
+.search button span { margin-left:.7rem; }
+.search-hint { color:#566c64; font:400 .58rem var(--mono); }
+.search-hint button { color:var(--mint); background:none; border:0; padding:0; cursor:pointer; text-decoration:underline; }
+.overview { display:grid; grid-template-columns:repeat(4,1fr); padding:0 clamp(1.2rem,5vw,5rem); border-bottom:1px solid var(--line); }
+.overview article { min-height:135px; display:flex; flex-direction:column; justify-content:center; padding:1.2rem 1.7rem; border-right:1px solid var(--line); }
+.overview article:first-child { border-left:1px solid var(--line); }
+.overview span { color:var(--muted); text-transform:uppercase; letter-spacing:.1em; font:400 .55rem var(--mono); }
+.overview strong { color:var(--green); font:500 clamp(1.35rem,2.4vw,2.2rem) var(--mono); margin:.35rem 0; }
+.overview small { color:#53675f; font:400 .55rem var(--mono); }
+.content { padding:clamp(3.5rem,6vw,6rem) clamp(1.2rem,5vw,5rem); min-height:560px; }
+.section-title { display:flex; align-items:end; justify-content:space-between; margin-bottom:1.8rem; }
+h2 { margin:0; font-size:clamp(2rem,4vw,3.8rem); letter-spacing:-.05em; line-height:1; font-weight:500; }
+.ghost { color:var(--mint); border:1px solid var(--line); background:transparent; padding:.6rem .9rem; font:500 .6rem var(--mono); cursor:pointer; }
+.ghost:hover { border-color:var(--mint); }
+.table-wrap { border:1px solid var(--line); overflow-x:auto; }
+table { width:100%; border-collapse:collapse; min-width:800px; }
+th { padding:.85rem 1rem; text-align:left; color:#566c64; text-transform:uppercase; letter-spacing:.1em; font:400 .52rem var(--mono); border-bottom:1px solid var(--line); }
+td { padding:1rem; border-bottom:1px solid rgba(35,49,45,.65); font-size:.75rem; }
+tbody tr:last-child td { border-bottom:0; }
+tbody tr { transition:background .15s; } tbody tr:hover { background:rgba(185,255,102,.025); }
+td code,.hash { font:400 .67rem var(--mono); }
+.link { color:var(--mint); text-decoration:none; }.link:hover { text-decoration:underline; }
+.truncate { display:inline-block; max-width:240px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; vertical-align:bottom; }
+.muted { color:var(--muted); }.reward { color:var(--green); }.orange { color:var(--orange); }
+.detail-head { display:flex; justify-content:space-between; align-items:flex-end; gap:2rem; margin-bottom:1.5rem; }
+.detail-head h2 { overflow-wrap:anywhere; }.back { color:var(--mint); font:500 .6rem var(--mono); text-decoration:none; }
+.detail-grid { display:grid; grid-template-columns:repeat(3,1fr); border:1px solid var(--line); margin-bottom:2rem; }
+.datum { min-height:105px; padding:1.2rem; border-right:1px solid var(--line); border-bottom:1px solid var(--line); display:flex; flex-direction:column; justify-content:space-between; min-width:0; }
+.datum:nth-child(3n) { border-right:0; }
+.datum span { color:var(--muted); text-transform:uppercase; font:400 .52rem var(--mono); letter-spacing:.1em; }
+.datum strong,.datum code { color:var(--text); font:500 .72rem var(--mono); overflow-wrap:anywhere; }
+.datum strong.big { font-size:1.15rem; color:var(--green); }
+.subheading { font-size:1rem; margin:2.5rem 0 1rem; }
+.raw { margin-top:2rem; border:1px solid var(--line); }
+.raw summary { cursor:pointer; padding:1rem; color:var(--muted); font:400 .62rem var(--mono); }
+.raw pre { padding:1rem; margin:0; border-top:1px solid var(--line); overflow:auto; color:#8da39a; font:400 .6rem/1.7 var(--mono); }
+.loading,.empty { min-height:300px; display:flex; flex-direction:column; align-items:center; justify-content:center; color:var(--muted); border:1px solid var(--line); text-align:center; }
+.loading span { width:24px; height:24px; border:2px solid var(--line); border-top-color:var(--green); border-radius:50%; animation:spin .7s linear infinite; margin-bottom:1rem; }
+@keyframes spin { to { transform:rotate(360deg); } }
+.empty strong { color:var(--text); font-size:1.2rem; }.empty p { max-width:500px; }.empty a { color:var(--mint); }
+footer { width:min(1480px,100%); margin:0 auto; border-top:1px solid var(--line); padding:2rem clamp(1.2rem,5vw,5rem); display:flex; justify-content:space-between; gap:1rem; color:#52665e; font:400 .55rem var(--mono); }
+.sr-only { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
+@media(max-width:850px){ .overview{grid-template-columns:1fr 1fr}.overview article:nth-child(odd){border-left:1px solid var(--line)}.detail-grid{grid-template-columns:1fr 1fr}.datum:nth-child(3n){border-right:1px solid var(--line)}.datum:nth-child(2n){border-right:0}footer{flex-direction:column}.hero::after{display:none} }
+@media(max-width:520px){ header{padding-inline:1rem}.brand small{display:none}.node-pill{border:0;padding:0}.overview{grid-template-columns:1fr 1fr;padding:0}.overview article{padding:1rem;min-height:110px}.search{grid-template-columns:1fr}.search button{height:48px}.detail-grid{grid-template-columns:1fr}.datum,.datum:nth-child(3n){border-right:0}.detail-head{align-items:flex-start;flex-direction:column-reverse} }
+@media(prefers-reduced-motion:reduce){*{animation-duration:.01ms!important;scroll-behavior:auto!important}}