diff options
Diffstat (limited to 'website')
| -rw-r--r-- | website/.htaccess | 22 | ||||
| -rw-r--r-- | website/README.md | 65 | ||||
| -rw-r--r-- | website/api/miner-stats/index.php | 130 | ||||
| -rw-r--r-- | website/api/node-info/index.php | 58 | ||||
| -rw-r--r-- | website/app.js | 186 | ||||
| -rw-r--r-- | website/assets/monzero-xmz-coin.png | bin | 0 -> 2210137 bytes | |||
| -rw-r--r-- | website/explorer/api.php | 126 | ||||
| -rw-r--r-- | website/explorer/app.js | 145 | ||||
| -rw-r--r-- | website/explorer/index.html | 63 | ||||
| -rw-r--r-- | website/explorer/styles.css | 76 | ||||
| -rw-r--r-- | website/index.html | 210 | ||||
| -rw-r--r-- | website/nginx-monzero.conf | 33 | ||||
| -rw-r--r-- | website/styles.css | 187 |
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 Binary files differnew file mode 100644 index 000000000..0e00071eb --- /dev/null +++ b/website/assets/monzero-xmz-coin.png 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) => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[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; } +} |
