diff options
| author | Monzero Build System <builds@monzero.org> | 2026-08-15 21:14:26 +0100 |
|---|---|---|
| committer | Monzero Build System <builds@monzero.org> | 2026-08-15 21:14:26 +0100 |
| commit | 28f1919249465d3230f45ed21686c5a836d56df0 (patch) | |
| tree | 948fe5e7052c45c366c24a82b485ae9d1d38e39b /explorer | |
| parent | 4f92268d7c16741cfb41e5bbe2aa46cc260a9ea5 (diff) | |
| download | monzero-core-28f1919249465d3230f45ed21686c5a836d56df0.tar.gz monzero-core-28f1919249465d3230f45ed21686c5a836d56df0.tar.xz monzero-core-28f1919249465d3230f45ed21686c5a836d56df0.zip | |
Establish Monzero Phase 0 and inactive assets baselinemonzero-phase0-assets-prototype-20260815
Diffstat (limited to 'explorer')
| -rw-r--r-- | explorer/README.md | 26 | ||||
| -rw-r--r-- | explorer/api.php | 126 | ||||
| -rw-r--r-- | explorer/app.js | 140 | ||||
| -rw-r--r-- | explorer/index.html | 59 | ||||
| -rw-r--r-- | explorer/styles.css | 73 |
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) => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[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}} |
