aboutsummaryrefslogtreecommitdiff
path: root/website
diff options
context:
space:
mode:
Diffstat (limited to 'website')
-rw-r--r--website/.htaccess22
-rw-r--r--website/README.md65
-rw-r--r--website/api/miner-stats/index.php130
-rw-r--r--website/api/node-info/index.php58
-rw-r--r--website/app.js186
-rw-r--r--website/assets/monzero-xmz-coin.pngbin0 -> 2210137 bytes
-rw-r--r--website/explorer/api.php126
-rw-r--r--website/explorer/app.js145
-rw-r--r--website/explorer/index.html63
-rw-r--r--website/explorer/styles.css76
-rw-r--r--website/index.html210
-rw-r--r--website/nginx-monzero.conf33
-rw-r--r--website/styles.css187
13 files changed, 1301 insertions, 0 deletions
diff --git a/website/.htaccess b/website/.htaccess
new file mode 100644
index 000000000..f437a279f
--- /dev/null
+++ b/website/.htaccess
@@ -0,0 +1,22 @@
+Options -Indexes
+
+<IfModule mod_headers.c>
+ Header always set X-Content-Type-Options "nosniff"
+ Header always set Referrer-Policy "strict-origin-when-cross-origin"
+ Header always set X-Frame-Options "DENY"
+</IfModule>
+
+<FilesMatch "\.(?:css|js|png|ico|svg|sha256)$">
+ <IfModule mod_expires.c>
+ ExpiresActive On
+ ExpiresDefault "access plus 7 days"
+ </IfModule>
+</FilesMatch>
+
+<FilesMatch "\.tar\.gz$">
+ ForceType application/gzip
+</FilesMatch>
+
+<FilesMatch "\.zip$">
+ ForceType application/zip
+</FilesMatch>
diff --git a/website/README.md b/website/README.md
new file mode 100644
index 000000000..c0ecd76ac
--- /dev/null
+++ b/website/README.md
@@ -0,0 +1,65 @@
+# Monzero website
+
+Website for `monzero.org`, including the PHP-backed block explorer at
+`/explorer/`. It contains no build-time dependencies.
+
+## Preview locally
+
+```bash
+cd website
+php -S 127.0.0.1:8080
+```
+
+Open `http://127.0.0.1:8080` for the website and
+`http://127.0.0.1:8080/explorer/` for the explorer.
+
+## VPS deployment
+
+Copy this directory's contents to `/var/www/monzero`, install
+`nginx-monzero.conf` as `/etc/nginx/sites-available/monzero`, enable the site,
+test Nginx, and reload it. The included Nginx route proxies only the homepage's
+`/get_info` request to the restricted local RPC service.
+
+On IONOS web hosting, upload the complete contents of this directory to the
+domain's assigned document root. PHP must be enabled so `/explorer/api.php` can
+proxy the allowlisted, read-only requests to the restricted public node.
+
+## Anonymous miner statistics
+
+The miner table uses an opt-in heartbeat endpoint. It never accepts or returns
+a wallet address, hostname, IP address, serial number, or hardware identifier.
+Each reporter creates a random UUID; PHP pseudonymizes it with a server-side
+HMAC secret before storage and exposes only the first eight pseudonym digits.
+
+Configure these secrets in the PHP environment (use independent random values):
+
+```text
+MONZERO_STATS_SECRET=<at least 32 random characters, server only>
+MONZERO_STATS_INGEST_TOKEN=<at least 24 random characters, team reporters>
+MONZERO_STATS_FILE=/an/apache-writable/private/path/miner-stats.json
+```
+
+For shared hosting without environment-variable controls, create
+`.monzero-miner-stats.php` one directory above the document root:
+
+```php
+<?php
+return [
+ 'MONZERO_STATS_SECRET' => 'server-only-random-value',
+ 'MONZERO_STATS_INGEST_TOKEN' => 'team-reporter-random-value',
+ 'MONZERO_STATS_FILE' => __DIR__ . '/.monzero-miner-stats.json',
+];
+```
+
+Generate suitable values with `openssl rand -hex 32`. Do not place the HMAC
+secret in a download or reporter. Give only the ingest token to miners who opt
+in, then run the reporter alongside their already-running local daemon:
+
+```bash
+MONZERO_STATS_TOKEN='team-ingest-token' \
+ python3 utils/monzero-miner-reporter.py --interval 60
+```
+
+The table counts a miner as active for three minutes after its last heartbeat.
+Hash rate and blocks found are self-reported. Block counts are derived from the
+local daemon log and are not consensus-verified leaderboard claims.
diff --git a/website/api/miner-stats/index.php b/website/api/miner-stats/index.php
new file mode 100644
index 000000000..43ae75fbc
--- /dev/null
+++ b/website/api/miner-stats/index.php
@@ -0,0 +1,130 @@
+<?php
+declare(strict_types=1);
+
+header('Content-Type: application/json; charset=utf-8');
+header('Cache-Control: no-store');
+header('X-Content-Type-Options: nosniff');
+
+const ACTIVE_SECONDS = 180;
+const MAX_RECORD_AGE = 604800;
+
+$fileConfig = [];
+$documentRoot = $_SERVER['DOCUMENT_ROOT'] ?? '';
+if (is_string($documentRoot) && $documentRoot !== '') {
+ $configPath = dirname($documentRoot) . '/.monzero-miner-stats.php';
+ if (is_file($configPath)) {
+ $loadedConfig = require $configPath;
+ if (is_array($loadedConfig)) $fileConfig = $loadedConfig;
+ }
+}
+
+function fail(int $status, string $message): never
+{
+ http_response_code($status);
+ echo json_encode(['status' => $message]);
+ exit;
+}
+
+function configValue(string $name): string
+{
+ global $fileConfig;
+ if (isset($fileConfig[$name]) && is_string($fileConfig[$name])) {
+ return trim($fileConfig[$name]);
+ }
+ $value = getenv($name);
+ return is_string($value) ? trim($value) : '';
+}
+
+function dataPath(): string
+{
+ $configured = configValue('MONZERO_STATS_FILE');
+ return $configured !== '' ? $configured : sys_get_temp_dir() . '/monzero-miner-stats.json';
+}
+
+function readRecords($handle): array
+{
+ rewind($handle);
+ $contents = stream_get_contents($handle);
+ if (!is_string($contents) || trim($contents) === '') return [];
+ $decoded = json_decode($contents, true);
+ return is_array($decoded) ? $decoded : [];
+}
+
+$secret = configValue('MONZERO_STATS_SECRET');
+if (strlen($secret) < 32) fail(503, 'NOT_CONFIGURED');
+
+$path = dataPath();
+$directory = dirname($path);
+if (!is_dir($directory) || !is_writable($directory)) fail(503, 'STORAGE_UNAVAILABLE');
+
+$handle = @fopen($path, 'c+');
+if ($handle === false || !flock($handle, LOCK_EX)) fail(503, 'STORAGE_UNAVAILABLE');
+$records = readRecords($handle);
+$now = time();
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $token = configValue('MONZERO_STATS_INGEST_TOKEN');
+ if (strlen($token) < 24) fail(503, 'INGEST_NOT_CONFIGURED');
+ $authorization = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
+ if (!hash_equals('Bearer ' . $token, $authorization)) fail(401, 'UNAUTHORIZED');
+
+ $raw = file_get_contents('php://input');
+ $input = json_decode(is_string($raw) ? $raw : '', true);
+ if (!is_array($input)) fail(400, 'INVALID_JSON');
+
+ $installationId = $input['installation_id'] ?? '';
+ $hashrate = filter_var($input['hashrate'] ?? null, FILTER_VALIDATE_FLOAT);
+ $blocks = filter_var($input['blocks_found'] ?? null, FILTER_VALIDATE_INT);
+ if (!is_string($installationId) || !preg_match('/^[a-f0-9-]{32,64}$/i', $installationId)) fail(400, 'INVALID_INSTALLATION_ID');
+ if ($hashrate === false || $hashrate < 0 || $hashrate > 1000000000000) fail(400, 'INVALID_HASHRATE');
+ if ($blocks === false || $blocks < 0 || $blocks > 1000000000) fail(400, 'INVALID_BLOCK_COUNT');
+
+ $privateId = hash_hmac('sha256', strtolower($installationId), $secret);
+ $records[$privateId] = [
+ 'hashrate' => round((float)$hashrate, 2),
+ 'blocks_found' => (int)$blocks,
+ 'updated_at' => $now,
+ ];
+}
+
+foreach ($records as $id => $record) {
+ if (!is_array($record) || ($record['updated_at'] ?? 0) < $now - MAX_RECORD_AGE) unset($records[$id]);
+}
+
+rewind($handle);
+ftruncate($handle, 0);
+fwrite($handle, json_encode($records, JSON_UNESCAPED_SLASHES));
+fflush($handle);
+flock($handle, LOCK_UN);
+fclose($handle);
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ echo json_encode(['status' => 'OK']);
+ exit;
+}
+if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
+ header('Allow: GET, POST');
+ fail(405, 'METHOD_NOT_ALLOWED');
+}
+
+$miners = [];
+foreach ($records as $id => $record) {
+ $active = (int)$record['updated_at'] >= $now - ACTIVE_SECONDS;
+ if (!$active) continue;
+ $miners[] = [
+ 'name' => 'Miner ' . strtoupper(substr($id, 0, 8)),
+ 'hashrate' => (float)$record['hashrate'],
+ 'blocks_found' => (int)$record['blocks_found'],
+ 'active' => true,
+ ];
+}
+usort($miners, static fn(array $a, array $b): int => $b['hashrate'] <=> $a['hashrate']);
+
+echo json_encode([
+ 'status' => 'OK',
+ 'generated_at' => $now,
+ 'active_miners' => count($miners),
+ 'total_hashrate' => array_sum(array_column($miners, 'hashrate')),
+ 'total_blocks' => array_sum(array_column($miners, 'blocks_found')),
+ 'miners' => $miners,
+], JSON_UNESCAPED_SLASHES);
diff --git a/website/api/node-info/index.php b/website/api/node-info/index.php
new file mode 100644
index 000000000..05f1610a4
--- /dev/null
+++ b/website/api/node-info/index.php
@@ -0,0 +1,58 @@
+<?php
+declare(strict_types=1);
+
+header('Content-Type: application/json; charset=utf-8');
+header('Cache-Control: no-store');
+header('X-Content-Type-Options: nosniff');
+
+if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
+ http_response_code(405);
+ header('Allow: POST');
+ echo json_encode(['status' => 'METHOD_NOT_ALLOWED']);
+ exit;
+}
+
+$url = 'http://node.monzero.org:6175/get_info';
+$error = '';
+
+if (function_exists('curl_init')) {
+ $curl = curl_init($url);
+ curl_setopt_array($curl, [
+ CURLOPT_POST => true,
+ CURLOPT_POSTFIELDS => '{}',
+ CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_CONNECTTIMEOUT => 2,
+ CURLOPT_TIMEOUT => 5,
+ ]);
+ $response = curl_exec($curl);
+ $status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
+ $error = curl_error($curl);
+ curl_close($curl);
+} else {
+ $context = stream_context_create(['http' => [
+ 'method' => 'POST',
+ 'header' => "Content-Type: application/json\r\nConnection: close\r\n",
+ 'content' => '{}',
+ 'timeout' => 5,
+ 'ignore_errors' => true,
+ ]]);
+ $response = @file_get_contents($url, false, $context);
+ $statusLine = $http_response_header[0] ?? '';
+ preg_match('/\s(\d{3})\s/', $statusLine, $match);
+ $status = isset($match[1]) ? (int)$match[1] : 0;
+ $error = 'HTTP ' . $status;
+}
+
+$decoded = is_string($response) ? json_decode($response, true) : null;
+if ($response === false || $status !== 200 || !is_array($decoded)) {
+ http_response_code(502);
+ echo json_encode(['status' => 'NODE_UNAVAILABLE']);
+ error_log('Monzero node status proxy failed: ' . $error);
+ exit;
+}
+
+$peerCount = (int)($decoded['incoming_connections_count'] ?? 0)
+ + (int)($decoded['outgoing_connections_count'] ?? 0);
+$decoded['connections_hidden'] = !empty($decoded['restricted']) && $peerCount === 0;
+echo json_encode($decoded, JSON_UNESCAPED_SLASHES);
diff --git a/website/app.js b/website/app.js
new file mode 100644
index 000000000..416446064
--- /dev/null
+++ b/website/app.js
@@ -0,0 +1,186 @@
+const menuButton = document.querySelector('.menu-toggle');
+const nav = document.querySelector('#site-nav');
+const toast = document.querySelector('.toast');
+
+menuButton?.addEventListener('click', () => {
+ const open = nav.classList.toggle('open');
+ menuButton.setAttribute('aria-expanded', String(open));
+});
+
+nav?.querySelectorAll('a').forEach((link) => link.addEventListener('click', () => {
+ nav.classList.remove('open');
+ menuButton?.setAttribute('aria-expanded', 'false');
+}));
+
+document.querySelectorAll('[data-copy]').forEach((button) => {
+ button.addEventListener('click', async () => {
+ try {
+ await navigator.clipboard.writeText(button.dataset.copy);
+ toast.classList.add('show');
+ window.setTimeout(() => toast.classList.remove('show'), 1600);
+ } catch {
+ button.textContent = 'Select manually';
+ }
+ });
+});
+
+document.querySelector('#year').textContent = new Date().getFullYear();
+
+const number = new Intl.NumberFormat('en-GB');
+const historyKey = 'monzero-network-chart-history-v1';
+let chartHistory = { hashrate: [], difficulty: [] };
+try {
+ chartHistory = { ...chartHistory, ...JSON.parse(localStorage.getItem(historyKey) || '{}') };
+} catch {
+ chartHistory = { hashrate: [], difficulty: [] };
+}
+
+function addChartPoint(series, value) {
+ const numeric = Number(value);
+ if (!Number.isFinite(numeric) || numeric < 0) return;
+ const points = Array.isArray(chartHistory[series]) ? chartHistory[series] : [];
+ const now = Date.now();
+ if (points.length && now - points.at(-1).time < 25000) return;
+ points.push({ time: now, value: numeric });
+ chartHistory[series] = points.slice(-120);
+ try {
+ localStorage.setItem(historyKey, JSON.stringify(chartHistory));
+ } catch {
+ // Charts still work for this page view when browser storage is disabled.
+ }
+ renderChart(series);
+}
+
+function renderChart(series) {
+ const points = chartHistory[series] || [];
+ const svg = document.querySelector(`#chart-${series}`);
+ const line = svg?.querySelector('.chart-line');
+ if (!line || !points.length) return;
+ const values = points.map((point) => point.value);
+ const minimum = Math.min(...values);
+ const maximum = Math.max(...values);
+ const spread = maximum - minimum || Math.max(maximum * .1, 1);
+ const coordinates = points.map((point, index) => {
+ const x = points.length === 1 ? 600 : (index / (points.length - 1)) * 600;
+ const y = 165 - ((point.value - minimum) / spread) * 150;
+ return `${x.toFixed(1)},${y.toFixed(1)}`;
+ });
+ line.setAttribute('points', coordinates.join(' '));
+}
+const fields = {
+ height: document.querySelector('#metric-height'),
+ difficulty: document.querySelector('#metric-difficulty'),
+ connections: document.querySelector('#metric-connections'),
+ rpc: document.querySelector('#metric-rpc'),
+ state: document.querySelector('#node-state'),
+ dot: document.querySelector('#node-dot'),
+};
+
+async function updateNodeStatus() {
+ try {
+ const response = await fetch('/api/node-info/', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: '{}',
+ signal: AbortSignal.timeout(6000),
+ });
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ const info = await response.json();
+ fields.height.textContent = number.format(info.height ?? 0);
+ fields.difficulty.textContent = number.format(info.difficulty ?? 0);
+ document.querySelector('#chart-difficulty-value').textContent = number.format(info.difficulty ?? 0);
+ addChartPoint('difficulty', info.difficulty);
+ const peerCount = info.peer_connections
+ ?? ((info.incoming_connections_count ?? 0) + (info.outgoing_connections_count ?? 0));
+ fields.connections.textContent = info.connections_hidden ? 'HIDDEN' : number.format(peerCount);
+ fields.connections.title = info.connections_hidden
+ ? 'Connection counts are intentionally hidden by the public restricted RPC.'
+ : 'Current incoming and outgoing P2P peer nodes, not unique people.';
+ fields.rpc.textContent = info.restricted ? 'RESTRICTED' : 'ONLINE';
+ fields.state.textContent = info.status === 'OK' ? 'Node online' : info.status;
+ fields.dot.className = 'status-dot online';
+ } catch {
+ fields.state.textContent = 'Status unavailable';
+ fields.rpc.textContent = '—';
+ fields.dot.className = 'status-dot offline';
+ }
+}
+
+updateNodeStatus();
+window.setInterval(updateNodeStatus, 30000);
+
+const minerFields = {
+ active: document.querySelector('#miner-active'),
+ hashrate: document.querySelector('#miner-hashrate'),
+ blocks: document.querySelector('#miner-blocks'),
+ updated: document.querySelector('#miner-updated'),
+ rows: document.querySelector('#miner-rows'),
+};
+
+function formatHashrate(value) {
+ let rate = Number(value) || 0;
+ const units = ['H/s', 'kH/s', 'MH/s', 'GH/s'];
+ let unit = 0;
+ while (rate >= 1000 && unit < units.length - 1) {
+ rate /= 1000;
+ unit += 1;
+ }
+ return `${rate.toLocaleString('en-GB', { maximumFractionDigits: rate < 10 ? 2 : 1 })} ${units[unit]}`;
+}
+
+function renderMiners(data) {
+ const miners = Array.isArray(data.miners) ? data.miners : [];
+ minerFields.active.textContent = number.format(data.active_miners ?? miners.length);
+ minerFields.hashrate.textContent = formatHashrate(data.total_hashrate ?? 0);
+ document.querySelector('#chart-hashrate-value').textContent = formatHashrate(data.total_hashrate ?? 0);
+ addChartPoint('hashrate', data.total_hashrate);
+ minerFields.blocks.textContent = number.format(data.total_blocks ?? 0);
+ minerFields.updated.textContent = data.generated_at
+ ? new Date(data.generated_at * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
+ : '—';
+
+ if (!miners.length) {
+ minerFields.rows.innerHTML = '<tr><td colspan="5" class="table-message">No miners have reported in the last three minutes.</td></tr>';
+ return;
+ }
+
+ minerFields.rows.replaceChildren(...miners.map((miner, index) => {
+ const row = document.createElement('tr');
+ const values = [
+ `#${index + 1}`,
+ miner.name,
+ formatHashrate(miner.hashrate),
+ number.format(miner.blocks_found ?? 0),
+ miner.active ? 'ACTIVE' : 'OFFLINE',
+ ];
+ values.forEach((value, column) => {
+ const cell = document.createElement('td');
+ cell.textContent = value;
+ if (column === 4) cell.className = miner.active ? 'miner-online' : '';
+ row.append(cell);
+ });
+ return row;
+ }));
+}
+
+async function updateMinerStats() {
+ try {
+ const response = await fetch('/api/miner-stats/', {
+ headers: { Accept: 'application/json' },
+ signal: AbortSignal.timeout(6000),
+ });
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ renderMiners(await response.json());
+ } catch {
+ minerFields.active.textContent = '—';
+ minerFields.hashrate.textContent = '—';
+ minerFields.blocks.textContent = '—';
+ minerFields.updated.textContent = '—';
+ minerFields.rows.innerHTML = '<tr><td colspan="5" class="table-message">Miner statistics are temporarily unavailable.</td></tr>';
+ }
+}
+
+updateMinerStats();
+window.setInterval(updateMinerStats, 30000);
+renderChart('hashrate');
+renderChart('difficulty');
diff --git a/website/assets/monzero-xmz-coin.png b/website/assets/monzero-xmz-coin.png
new file mode 100644
index 000000000..0e00071eb
--- /dev/null
+++ b/website/assets/monzero-xmz-coin.png
Binary files differ
diff --git a/website/explorer/api.php b/website/explorer/api.php
new file mode 100644
index 000000000..109485dab
--- /dev/null
+++ b/website/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/website/explorer/app.js b/website/explorer/app.js
new file mode 100644
index 000000000..01381e61e
--- /dev/null
+++ b/website/explorer/app.js
@@ -0,0 +1,145 @@
+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 = info.restricted
+ ? 'HIDDEN'
+ : number.format((info.incoming_connections_count || 0) + (info.outgoing_connections_count || 0));
+ $('#stat-connections').title = info.restricted
+ ? 'Connection counts are intentionally hidden by the public restricted RPC.'
+ : 'Current incoming and outgoing P2P connections.';
+ $('#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/website/explorer/index.html b/website/explorer/index.html
new file mode 100644
index 000000000..aa2c2fe20
--- /dev/null
+++ b/website/explorer/index.html
@@ -0,0 +1,63 @@
+<!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="icon" href="../assets/monzero-xmz-coin.png">
+ <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="header-actions">
+ <a class="site-link" href="../">Monzero home</a>
+ <div class="node-pill"><span id="node-dot"></span><span id="node-label">Connecting</span></div>
+ </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>Hidden on restricted RPC</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/website/explorer/styles.css b/website/explorer/styles.css
new file mode 100644
index 000000000..7961df623
--- /dev/null
+++ b/website/explorer/styles.css
@@ -0,0 +1,76 @@
+@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; }
+.header-actions { display:flex; align-items:center; gap:1rem; }
+.site-link { color:var(--mint); text-decoration:none; font:500 .6rem var(--mono); text-transform:uppercase; letter-spacing:.08em; }
+.site-link:hover { text-decoration:underline; }
+#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,.site-link{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}}
diff --git a/website/index.html b/website/index.html
new file mode 100644
index 000000000..d9503f5bc
--- /dev/null
+++ b/website/index.html
@@ -0,0 +1,210 @@
+<!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 (XMZ) is an experimental, community-run privacy coin network powered by RandomX.">
+ <meta name="theme-color" content="#07110f">
+ <title>Monzero — Private by design. Open by nature.</title>
+ <link rel="icon" href="assets/monzero-xmz-coin.png">
+ <link rel="stylesheet" href="styles.css">
+</head>
+<body>
+ <a class="skip-link" href="#main">Skip to content</a>
+
+ <header class="site-header">
+ <a class="brand" href="#top" aria-label="Monzero home">
+ <span class="brand-mark">MZ</span>
+ <span>MONZERO</span>
+ </a>
+ <button class="menu-toggle" type="button" aria-expanded="false" aria-controls="site-nav">Menu</button>
+ <nav id="site-nav" aria-label="Primary navigation">
+ <a href="#network">Network</a>
+ <a href="#miners">Miners</a>
+ <a href="explorer/">Explorer</a>
+ <a href="#about">About</a>
+ <a href="#mine">Mine</a>
+ <a href="#faq">FAQ</a>
+ <a class="nav-download" href="#download">Download</a>
+ </nav>
+ </header>
+
+ <main id="main">
+ <section class="hero" id="top">
+ <div class="hero-copy">
+ <p class="eyebrow"><span class="pulse"></span> Genesis network · Public node online</p>
+ <h1>Privacy should be <em>ordinary.</em></h1>
+ <p class="hero-lead">Monzero is an independent, community-run digital currency built for private transactions, open participation, and CPU-friendly mining.</p>
+ <div class="hero-actions">
+ <a class="button primary" href="#download">Get Monzero</a>
+ <a class="button secondary" href="explorer/">Explore the chain <span aria-hidden="true">↗</span></a>
+ </div>
+ <div class="hero-proof" aria-label="Core network properties">
+ <span>RandomX PoW</span>
+ <span>2-minute blocks</span>
+ <span>No premine</span>
+ </div>
+ </div>
+ <div class="coin-stage" aria-label="Monzero XMZ coin artwork">
+ <div class="orbit orbit-one"></div>
+ <div class="orbit orbit-two"></div>
+ <div class="coin-glow"></div>
+ <img src="assets/monzero-xmz-coin.png" alt="Monzero XMZ coin" width="627" height="627">
+ <span class="coordinate coordinate-a">86 / 87 / 88</span>
+ <span class="coordinate coordinate-b">XMZ · MAINNET</span>
+ </div>
+ </section>
+
+ <section class="ticker" aria-label="Monzero network summary">
+ <div><span>Asset</span><strong>XMZ</strong></div>
+ <div><span>Target supply</span><strong>100M</strong></div>
+ <div><span>Block target</span><strong>120 SEC</strong></div>
+ <div><span>Precision</span><strong>11 DECIMALS</strong></div>
+ </section>
+
+ <section class="network section" id="network">
+ <div class="section-heading">
+ <p class="eyebrow">Live network</p>
+ <h2>A chain you can verify.</h2>
+ <p>The official public node gives every wallet and miner a stable entry point into the Monzero network.</p>
+ </div>
+ <div class="status-panel">
+ <div class="status-topline">
+ <div><span id="node-dot" class="status-dot"></span><strong id="node-state">Checking node</strong></div>
+ <code>node.monzero.org</code>
+ </div>
+ <div class="metrics">
+ <article><span>Block height</span><strong id="metric-height">—</strong></article>
+ <article><span>Network difficulty</span><strong id="metric-difficulty">—</strong></article>
+ <article><span>Peer nodes</span><strong id="metric-connections">—</strong></article>
+ <article><span>RPC status</span><strong id="metric-rpc">—</strong></article>
+ </div>
+ <div class="node-addresses">
+ <p><span>P2P</span><code>node.monzero.org:6174</code><button class="copy" data-copy="node.monzero.org:6174">Copy</button></p>
+ <p><span>Restricted RPC</span><code>node.monzero.org:6175</code><button class="copy" data-copy="node.monzero.org:6175">Copy</button></p>
+ </div>
+ <p><a class="button secondary" href="explorer/">Open block explorer <span aria-hidden="true">↗</span></a></p>
+ </div>
+ </section>
+
+ <section class="miners section" id="miners">
+ <div class="section-heading">
+ <p class="eyebrow">Mining network</p>
+ <h2>Anonymous miner ranking.</h2>
+ <p>Live, opt-in statistics from participating miners. No wallet address, IP address, hostname, or hardware identifier is displayed.</p>
+ </div>
+ <div class="miner-panel">
+ <div class="miner-summary">
+ <article><span>Active miners</span><strong id="miner-active">—</strong></article>
+ <article><span>Reported hash rate</span><strong id="miner-hashrate">—</strong></article>
+ <article><span>Blocks found</span><strong id="miner-blocks">—</strong></article>
+ <article><span>Last update</span><strong id="miner-updated">—</strong></article>
+ </div>
+ <div class="table-scroll">
+ <table class="miner-table">
+ <thead><tr><th>Rank</th><th>Anonymous miner</th><th>Speed</th><th>Blocks found</th><th>Status</th></tr></thead>
+ <tbody id="miner-rows"><tr><td colspan="5" class="table-message">Loading miner statistics…</td></tr></tbody>
+ </table>
+ </div>
+ <div class="chart-grid" aria-label="Live mining charts">
+ <article class="chart-card">
+ <div><span>Reported hash rate</span><strong id="chart-hashrate-value">—</strong></div>
+ <svg id="chart-hashrate" viewBox="0 0 600 180" role="img" aria-label="Reported hash rate over time"><path class="chart-gridline" d="M0 45H600M0 90H600M0 135H600"></path><polyline class="chart-line" points=""></polyline></svg>
+ <small>Rolling browser history · H/s</small>
+ </article>
+ <article class="chart-card">
+ <div><span>Network difficulty</span><strong id="chart-difficulty-value">—</strong></div>
+ <svg id="chart-difficulty" viewBox="0 0 600 180" role="img" aria-label="Network difficulty over time"><path class="chart-gridline" d="M0 45H600M0 90H600M0 135H600"></path><polyline class="chart-line difficulty" points=""></polyline></svg>
+ <small>Rolling browser history · network difficulty</small>
+ </article>
+ </div>
+ <p class="telemetry-note">Statistics are self-reported and miners disappear after three minutes without a heartbeat. A random installation ID is pseudonymized by the server.</p>
+ </div>
+ </section>
+
+ <section class="principles section" id="about">
+ <div class="section-heading compact">
+ <p class="eyebrow">Why Monzero</p>
+ <h2>Built around participation.</h2>
+ </div>
+ <div class="principle-grid">
+ <article>
+ <span class="card-number">01</span>
+ <h3>Private transactions</h3>
+ <p>Monzero inherits established privacy-focused transaction technology, including ring signatures, stealth addresses, and confidential amounts.</p>
+ </article>
+ <article>
+ <span class="card-number">02</span>
+ <h3>Consumer hardware</h3>
+ <p>RandomX proof of work is designed for general-purpose CPUs, keeping participation open to ordinary computers.</p>
+ </article>
+ <article>
+ <span class="card-number">03</span>
+ <h3>Independent chain</h3>
+ <p>XMZ has its own genesis block, network identity, address prefixes, ports, and monetary policy. It is not Monero.</p>
+ </article>
+ </div>
+ </section>
+
+ <section class="mine section" id="mine">
+ <div class="mine-copy">
+ <p class="eyebrow">Join the network</p>
+ <h2>Turn spare CPU time into network security.</h2>
+ <p>Run your own daemon, create a fresh Monzero wallet, and mine directly to an address you control. Solo mining rewards the machine that finds a block; rewards unlock after 60 blocks.</p>
+ <div class="warning"><strong>Keep it separate.</strong> Never reuse a Monero seed, keys, or wallet file with Monzero.</div>
+ </div>
+ <ol class="steps">
+ <li><span>1</span><div><strong>Start the node</strong><code>./start-node.sh</code></div></li>
+ <li><span>2</span><div><strong>Start the separate miner</strong><code>./start-mining.sh YOUR_ADDRESS 1</code></div></li>
+ <li><span>3</span><div><strong>Open the wallet when needed</strong><code>./open-wallet.sh /path/to/wallet</code></div></li>
+ </ol>
+ </section>
+
+ <section class="download section" id="download">
+ <div class="download-card">
+ <div>
+ <p class="eyebrow">Genesis pre2</p>
+ <h2>Download Monzero</h2>
+ <p>Private pre-release builds for Windows and Linux x86-64. Both include the node, command-line wallet, separate mining controls, checksums, and setup guidance. The Windows package also includes the graphical wallet.</p>
+ <p class="release-note">Experimental and unaudited software for the private pre-release Monzero network.</p>
+ </div>
+ <div class="download-action">
+ <div class="release-download">
+ <a class="button primary wide" href="downloads/monzero-genesis-pre2-windows-x64.zip" download>Windows x64 <span>57 MB · ZIP</span></a>
+ <a class="checksum-link" href="downloads/monzero-genesis-pre2-windows-x64.zip.sha256" download>Windows SHA-256 checksum</a>
+ <code class="hash">784902a16ceb85722327dc800fb181d56feb180609b4df575b1f777ee475f3f2</code>
+ </div>
+ <div class="release-download">
+ <a class="button secondary wide" href="downloads/monzero-genesis-pre2-linux-x86_64.tar.gz" download>Linux x86-64 <span>13 MB · TAR.GZ</span></a>
+ <a class="checksum-link" href="downloads/monzero-genesis-pre2-linux-x86_64.tar.gz.sha256" download>Linux SHA-256 checksum</a>
+ <code class="hash">baf75989bea783ec0488978ee3e2218b1bc87f9d44827019cf7e96b370dddef8</code>
+ </div>
+ <p class="unsigned-note">Windows binaries are currently unsigned. Windows SmartScreen may show an unknown-publisher warning; verify the checksum before running them.</p>
+ </div>
+ </div>
+ </section>
+
+ <section class="faq section" id="faq">
+ <div class="section-heading compact">
+ <p class="eyebrow">Questions</p>
+ <h2>Before you begin.</h2>
+ </div>
+ <div class="faq-list">
+ <details><summary>Why is my mining balance still zero?</summary><p>Monzero currently uses solo mining. You receive a coinbase reward only when your machine finds a block; simply hashing does not create a gradual balance. A found reward remains locked for 60 blocks.</p></details>
+ <details><summary>Is Monzero the same network as Monero?</summary><p>No. Monzero is an independent experimental chain with a different genesis block, network ID, ports, address prefixes, ticker, and monetary policy.</p></details>
+ <details><summary>Can I reuse my existing seed?</summary><p>No. Create a new wallet and seed exclusively for Monzero. Reusing keys across forks can seriously damage privacy.</p></details>
+ <details><summary>Is this a production release?</summary><p>Not yet. Genesis pre2 is private pre-release software. Consensus and cryptographic changes have not received an independent audit.</p></details>
+ </div>
+ </section>
+ </main>
+
+ <footer>
+ <a class="brand" href="#top"><span class="brand-mark">MZ</span><span>MONZERO</span></a>
+ <p>Experimental software. Verify everything.</p>
+ <p>© <span id="year"></span> Monzero contributors · <a href="https://monzero.org">monzero.org</a></p>
+ </footer>
+
+ <div class="toast" role="status" aria-live="polite">Copied to clipboard</div>
+ <script src="app.js?v=20260815-2"></script>
+</body>
+</html>
diff --git a/website/nginx-monzero.conf b/website/nginx-monzero.conf
new file mode 100644
index 000000000..4e69577c4
--- /dev/null
+++ b/website/nginx-monzero.conf
@@ -0,0 +1,33 @@
+server {
+ listen 80;
+ listen [::]:80;
+ server_name monzero.org www.monzero.org;
+
+ root /var/www/monzero;
+ index index.html;
+
+ location / {
+ try_files $uri $uri/ =404;
+ }
+
+ # Same-origin, read-only node status endpoint used by the homepage.
+ location = /api/node-info {
+ limit_except POST { deny all; }
+ proxy_pass http://127.0.0.1:6175/get_info;
+ proxy_set_header Content-Type application/json;
+ proxy_pass_request_headers on;
+ proxy_connect_timeout 2s;
+ proxy_read_timeout 5s;
+ add_header Cache-Control "no-store" always;
+ }
+
+ location ~* \.(?:css|js|png|ico|svg|tar\.gz|sha256)$ {
+ expires 7d;
+ add_header Cache-Control "public, max-age=604800";
+ try_files $uri =404;
+ }
+
+ add_header X-Content-Type-Options nosniff always;
+ add_header Referrer-Policy strict-origin-when-cross-origin always;
+ add_header X-Frame-Options DENY always;
+}
diff --git a/website/styles.css b/website/styles.css
new file mode 100644
index 000000000..d346d555f
--- /dev/null
+++ b/website/styles.css
@@ -0,0 +1,187 @@
+@import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Manrope:wght@400;500;600;700&display=swap');
+
+:root {
+ --ink: #eaf7f2;
+ --muted: #91a7a0;
+ --bg: #06100e;
+ --surface: #0b1815;
+ --line: rgba(199, 255, 228, .13);
+ --lime: #b9ff66;
+ --mint: #62e7af;
+ --copper: #d78755;
+ --mono: "DM Mono", monospace;
+ --sans: "Manrope", sans-serif;
+}
+
+* { box-sizing: border-box; }
+html { scroll-behavior: smooth; }
+body { margin: 0; color: var(--ink); background: var(--bg); font-family: var(--sans); line-height: 1.6; overflow-x: hidden; }
+body::before { content: ""; position: fixed; inset: 0; pointer-events: none; opacity: .22; z-index: 10; 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='.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.12'/%3E%3C/svg%3E"); }
+a { color: inherit; }
+button, a { -webkit-tap-highlight-color: transparent; }
+.skip-link { position: fixed; left: 1rem; top: -5rem; background: var(--lime); color: #07110f; padding: .7rem 1rem; z-index: 100; }
+.skip-link:focus { top: 1rem; }
+
+.site-header { height: 84px; padding: 0 clamp(1.2rem, 4vw, 4.5rem); display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line); position: absolute; width: 100%; z-index: 5; }
+.brand { text-decoration: none; display: inline-flex; gap: .75rem; align-items: center; font-size: .86rem; letter-spacing: .19em; font-weight: 700; }
+.brand-mark { width: 32px; height: 32px; display: grid; place-items: center; border: 1px solid var(--lime); border-radius: 50%; color: var(--lime); font: 500 .66rem var(--mono); letter-spacing: 0; }
+nav { display: flex; align-items: center; gap: clamp(1.1rem, 2.5vw, 2.4rem); }
+nav a { color: #b6c7c1; text-decoration: none; font-size: .83rem; transition: color .2s; }
+nav a:hover { color: var(--ink); }
+.nav-download { border: 1px solid var(--line); border-radius: 2rem; padding: .55rem 1rem; }
+.menu-toggle { display: none; color: var(--ink); background: none; border: 1px solid var(--line); padding: .5rem .8rem; }
+
+.hero { min-height: 800px; height: min(92vh, 980px); display: grid; grid-template-columns: minmax(0, 1fr) minmax(360px, .9fr); align-items: center; gap: 2rem; padding: 8rem clamp(1.2rem, 7vw, 8rem) 4rem; position: relative; isolation: isolate; background: radial-gradient(circle at 80% 40%, rgba(40, 144, 102, .15), transparent 34%), linear-gradient(105deg, #071310 0%, #06100e 58%, #081310 100%); }
+.hero::after { content: ""; position: absolute; inset: 0; z-index: -1; opacity: .2; background-image: linear-gradient(var(--line) 1px, transparent 1px), linear-gradient(90deg, var(--line) 1px, transparent 1px); background-size: 72px 72px; mask-image: linear-gradient(to right, #000, transparent 80%); }
+.hero-copy { max-width: 720px; }
+.eyebrow { margin: 0 0 1.2rem; color: var(--mint); font: 500 .68rem var(--mono); letter-spacing: .17em; text-transform: uppercase; }
+.pulse { display: inline-block; width: 7px; height: 7px; background: var(--lime); border-radius: 50%; margin-right: .55rem; box-shadow: 0 0 0 5px rgba(185, 255, 102, .1); animation: pulse 2.3s infinite; }
+@keyframes pulse { 50% { box-shadow: 0 0 0 9px rgba(185, 255, 102, 0); } }
+h1 { font-size: clamp(3.5rem, 7.4vw, 7.8rem); line-height: .9; letter-spacing: -.065em; margin: 0; max-width: 850px; font-weight: 500; }
+h1 em { display: block; font-weight: 500; color: var(--lime); font-style: normal; }
+.hero-lead { max-width: 610px; color: #a9bcb5; font-size: clamp(1rem, 1.3vw, 1.22rem); margin: 2rem 0; }
+.hero-actions { display: flex; gap: .8rem; flex-wrap: wrap; }
+.button { min-height: 52px; display: inline-flex; align-items: center; justify-content: center; gap: .9rem; padding: .8rem 1.4rem; text-decoration: none; border-radius: 3px; font-size: .85rem; font-weight: 700; transition: transform .2s, background .2s; }
+.button:hover { transform: translateY(-2px); }
+.button.primary { color: #07100e; background: var(--lime); }
+.button.primary:hover { background: #ccff8f; }
+.button.secondary { border: 1px solid var(--line); background: rgba(255,255,255,.025); }
+.hero-proof { display: flex; gap: 1.5rem; margin-top: 2.2rem; color: #71877f; font: .65rem var(--mono); text-transform: uppercase; letter-spacing: .08em; }
+.coin-stage { min-height: 560px; display: grid; place-items: center; position: relative; }
+.coin-stage img { width: min(36vw, 540px); filter: drop-shadow(0 35px 50px rgba(0,0,0,.5)); position: relative; z-index: 2; animation: float 7s ease-in-out infinite; }
+@keyframes float { 50% { transform: translateY(-14px) rotate(1.5deg); } }
+.coin-glow { position: absolute; width: 55%; aspect-ratio: 1; border-radius: 50%; background: rgba(185,255,102,.22); filter: blur(90px); }
+.orbit { position: absolute; width: 78%; aspect-ratio: 1; border: 1px solid rgba(185,255,102,.18); border-radius: 50%; transform: rotate(19deg) scaleY(.38); }
+.orbit-two { width: 92%; transform: rotate(-28deg) scaleY(.28); border-color: rgba(98,231,175,.12); }
+.coordinate { position: absolute; font: .58rem var(--mono); letter-spacing: .12em; color: #5b746b; }
+.coordinate-a { left: 2%; top: 25%; }.coordinate-b { right: 2%; bottom: 20%; }
+
+.ticker { min-height: 118px; border-block: 1px solid var(--line); display: grid; grid-template-columns: repeat(4, 1fr); padding: 0 clamp(1.2rem, 7vw, 8rem); }
+.ticker div { display: flex; justify-content: center; flex-direction: column; padding: 1.3rem 2rem; border-right: 1px solid var(--line); }
+.ticker div:first-child { border-left: 1px solid var(--line); }
+.ticker span, .metrics span { color: #6f857d; font: .63rem var(--mono); letter-spacing: .11em; text-transform: uppercase; }
+.ticker strong { color: var(--lime); font: 500 1rem var(--mono); margin-top: .3rem; }
+
+.section { padding: clamp(5rem, 10vw, 9rem) clamp(1.2rem, 7vw, 8rem); }
+.section-heading { max-width: 620px; }
+.section-heading.compact { margin-bottom: 3.2rem; }
+h2 { margin: 0 0 1.2rem; font-size: clamp(2.4rem, 5vw, 5rem); font-weight: 500; line-height: 1; letter-spacing: -.055em; }
+.section-heading > p:last-child, .mine-copy > p { color: var(--muted); }
+.network { display: grid; grid-template-columns: .72fr 1.28fr; gap: clamp(3rem, 8vw, 9rem); align-items: center; }
+.status-panel { border: 1px solid var(--line); background: linear-gradient(145deg, rgba(255,255,255,.035), rgba(255,255,255,.008)); box-shadow: 0 30px 90px rgba(0,0,0,.2); }
+.status-topline { display: flex; justify-content: space-between; align-items: center; padding: 1.15rem 1.4rem; border-bottom: 1px solid var(--line); font-size: .75rem; }
+.status-topline > div { display: flex; align-items: center; gap: .65rem; }
+.status-topline code { color: #70877f; font-family: var(--mono); }
+.status-dot { width: 7px; height: 7px; border-radius: 50%; background: #e6ae59; box-shadow: 0 0 12px currentColor; }
+.status-dot.online { background: var(--lime); }
+.status-dot.offline { background: #ed735f; }
+.metrics { display: grid; grid-template-columns: 1fr 1fr; }
+.metrics article { min-height: 124px; padding: 1.4rem; border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); display: flex; flex-direction: column; justify-content: space-between; }
+.metrics article:nth-child(even) { border-right: 0; }
+.metrics strong { font: 500 clamp(1.25rem, 2vw, 2rem) var(--mono); color: var(--ink); }
+.node-addresses { padding: .7rem 1.4rem; }
+.node-addresses p { display: grid; grid-template-columns: 100px 1fr auto; gap: .8rem; align-items: center; font-size: .7rem; }
+.node-addresses span { color: #6f857d; }.node-addresses code { overflow: hidden; text-overflow: ellipsis; }
+.copy { color: var(--lime); border: 0; background: none; cursor: pointer; font: .65rem var(--mono); }
+
+.miners { background: #091411; border-block: 1px solid var(--line); }
+.miners .section-heading { margin-bottom: 3rem; }
+.miner-panel { border: 1px solid var(--line); background: rgba(255,255,255,.012); }
+.miner-summary { display: grid; grid-template-columns: repeat(4, 1fr); border-bottom: 1px solid var(--line); }
+.miner-summary article { min-height: 110px; padding: 1.4rem; border-right: 1px solid var(--line); display: flex; flex-direction: column; justify-content: space-between; }
+.miner-summary article:last-child { border: 0; }
+.miner-summary span { color: #6f857d; font: .63rem var(--mono); letter-spacing: .11em; text-transform: uppercase; }
+.miner-summary strong { font: 500 clamp(1rem, 1.7vw, 1.5rem) var(--mono); }
+.table-scroll { overflow-x: auto; }
+.miner-table { width: 100%; border-collapse: collapse; font: .73rem var(--mono); }
+.miner-table th, .miner-table td { padding: 1.1rem 1.4rem; text-align: left; border-bottom: 1px solid var(--line); white-space: nowrap; }
+.miner-table th { color: #6f857d; font-size: .59rem; letter-spacing: .1em; text-transform: uppercase; }
+.miner-table tbody tr:first-child td:first-child { color: var(--lime); }
+.miner-online { color: var(--lime); }
+.table-message { color: var(--muted); text-align: center !important; }
+.telemetry-note { margin: 0; padding: 1rem 1.4rem; color: #71877f; font-size: .68rem; }
+.chart-grid { display: grid; grid-template-columns: 1fr 1fr; border-bottom: 1px solid var(--line); }
+.chart-card { padding: 1.4rem; min-width: 0; border-right: 1px solid var(--line); }
+.chart-card:last-child { border-right: 0; }
+.chart-card > div { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
+.chart-card span, .chart-card small { color: #6f857d; font: .61rem var(--mono); letter-spacing: .08em; text-transform: uppercase; }
+.chart-card strong { font: 500 .9rem var(--mono); }
+.chart-card svg { display: block; width: 100%; height: 180px; margin: .8rem 0; overflow: visible; }
+.chart-gridline { fill: none; stroke: var(--line); stroke-width: 1; }
+.chart-line { fill: none; stroke: var(--lime); stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; vector-effect: non-scaling-stroke; }
+.chart-line.difficulty { stroke: var(--mint); }
+
+.principles { background: #091411; border-block: 1px solid var(--line); }
+.principle-grid { display: grid; grid-template-columns: repeat(3, 1fr); border: 1px solid var(--line); }
+.principle-grid article { min-height: 310px; padding: 2rem; border-right: 1px solid var(--line); display: flex; flex-direction: column; }
+.principle-grid article:last-child { border: 0; }
+.card-number { color: var(--mint); font: .65rem var(--mono); }
+h3 { margin: auto 0 1rem; font-size: 1.4rem; font-weight: 600; }
+.principle-grid p { color: var(--muted); font-size: .88rem; margin: 0; }
+
+.mine { display: grid; grid-template-columns: 1fr 1fr; gap: clamp(3rem, 9vw, 10rem); align-items: center; }
+.warning { margin-top: 2rem; padding: 1.2rem 1.3rem; border-left: 2px solid var(--copper); background: rgba(215,135,85,.07); color: #bba99e; font-size: .82rem; }
+.warning strong { color: #e5c6b3; }
+.steps { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--line); }
+.steps li { display: grid; grid-template-columns: 50px 1fr; align-items: center; gap: 1rem; min-height: 110px; border-bottom: 1px solid var(--line); }
+.steps > li > span { color: var(--lime); font: .7rem var(--mono); }
+.steps div { display: flex; flex-direction: column; gap: .5rem; }
+.steps strong { font-weight: 600; }.steps code { color: #789087; font: .75rem var(--mono); }
+
+.download { padding-top: 2rem; }
+.download-card { padding: clamp(2rem, 5vw, 4.5rem); display: grid; grid-template-columns: 1fr .8fr; gap: 4rem; align-items: center; border: 1px solid rgba(185,255,102,.25); background: radial-gradient(circle at 85% 30%, rgba(185,255,102,.12), transparent 32%), #0b1815; }
+.download-card h2 { font-size: clamp(2.3rem, 4vw, 4rem); }
+.download-card p { max-width: 620px; color: var(--muted); }
+.download-card .release-note { color: #c8a78f; font-size: .75rem; }
+.download-action { display: flex; flex-direction: column; align-items: stretch; gap: 1rem; min-width: 0; }
+.release-download { display: flex; flex-direction: column; gap: .65rem; padding: 1rem; border: 1px solid var(--line); background: rgba(0,0,0,.12); }
+.button.wide { justify-content: space-between; }.button.wide span { font: .65rem var(--mono); opacity: .7; }
+.checksum-link { color: var(--mint); text-align: center; font-size: .72rem; }
+.hash { display: block; overflow-wrap: anywhere; color: #60776f; font: .58rem/1.7 var(--mono); text-align: center; }
+.unsigned-note { margin: 0 !important; color: #c8a78f !important; font-size: .68rem; }
+
+.faq { display: grid; grid-template-columns: .7fr 1.3fr; gap: clamp(3rem, 8vw, 9rem); }
+.faq-list { border-top: 1px solid var(--line); }
+details { border-bottom: 1px solid var(--line); }
+summary { cursor: pointer; list-style: none; padding: 1.45rem 2.5rem 1.45rem 0; position: relative; font-size: .95rem; }
+summary::-webkit-details-marker { display: none; }
+summary::after { content: "+"; position: absolute; right: .4rem; color: var(--lime); font: 1.2rem var(--mono); }
+details[open] summary::after { content: "−"; }
+details p { color: var(--muted); font-size: .84rem; max-width: 700px; margin: 0; padding: 0 2rem 1.5rem 0; }
+
+footer { min-height: 120px; border-top: 1px solid var(--line); padding: 2rem clamp(1.2rem, 7vw, 8rem); display: flex; justify-content: space-between; align-items: center; gap: 2rem; color: #60756d; font-size: .68rem; }
+footer p { margin: 0; } footer a { color: inherit; }
+.toast { position: fixed; bottom: 1.5rem; left: 50%; transform: translate(-50%, 150%); padding: .7rem 1rem; background: var(--lime); color: #07110f; font: .68rem var(--mono); z-index: 20; transition: transform .25s; }
+.toast.show { transform: translate(-50%, 0); }
+
+@media (max-width: 900px) {
+ .hero { height: auto; min-height: 950px; grid-template-columns: 1fr; padding-top: 10rem; }
+ .coin-stage { min-height: 360px; order: -1; margin-bottom: -3rem; }
+ .coin-stage img { width: min(78vw, 430px); }
+ .network, .mine, .faq, .download-card { grid-template-columns: 1fr; }
+ .miner-summary { grid-template-columns: 1fr 1fr; }
+ .chart-grid { grid-template-columns: 1fr; }
+ .chart-card { border-right: 0; border-bottom: 1px solid var(--line); }
+ .miner-summary article:nth-child(2) { border-right: 0; }
+ .miner-summary article:nth-child(-n+2) { border-bottom: 1px solid var(--line); }
+ .principle-grid { grid-template-columns: 1fr; }
+ .principle-grid article { min-height: 230px; border-right: 0; border-bottom: 1px solid var(--line); }
+ .ticker { grid-template-columns: 1fr 1fr; }.ticker div:nth-child(odd) { border-left: 1px solid var(--line); }
+ nav { display: none; position: absolute; top: 84px; left: 0; right: 0; padding: 1.5rem; flex-direction: column; align-items: stretch; background: #07110f; border-bottom: 1px solid var(--line); }
+ nav.open { display: flex; }.menu-toggle { display: block; }
+}
+
+@media (max-width: 560px) {
+ .hero { min-height: 880px; }.coin-stage { min-height: 300px; }
+ .hero-proof { gap: .7rem; flex-wrap: wrap; }
+ .ticker div { padding: 1.1rem .8rem; }.ticker strong { font-size: .8rem; }
+ .metrics { grid-template-columns: 1fr; }.metrics article { border-right: 0; }
+ .miner-summary { grid-template-columns: 1fr; }
+ .miner-summary article, .miner-summary article:nth-child(2) { border-right: 0; border-bottom: 1px solid var(--line); }
+ .node-addresses p { grid-template-columns: 1fr auto; }.node-addresses span { grid-column: 1 / -1; }
+ footer { flex-direction: column; align-items: flex-start; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
+}